io.github.palexdev.virtualizedfx.controls.behaviors.VFXScrollBarBehavior Maven / Gradle / Ivy
Show all versions of virtualizedfx Show documentation
/*
* Copyright (C) 2024 Parisi Alessandro - [email protected]
* This file is part of VirtualizedFX (https://github.com/palexdev/VirtualizedFX)
*
* VirtualizedFX 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.
*
* VirtualizedFX 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 VirtualizedFX. If not, see .
*/
package io.github.palexdev.virtualizedfx.controls.behaviors;
import io.github.palexdev.mfxcore.base.beans.range.DoubleRange;
import io.github.palexdev.mfxcore.behavior.BehaviorBase;
import io.github.palexdev.mfxcore.utils.NumberUtils;
import io.github.palexdev.mfxcore.utils.fx.ScrollUtils;
import io.github.palexdev.mfxeffects.animations.Animations.KeyFrames;
import io.github.palexdev.mfxeffects.animations.Animations.PauseBuilder;
import io.github.palexdev.mfxeffects.animations.Animations.TimelineBuilder;
import io.github.palexdev.mfxeffects.animations.MomentumTransition;
import io.github.palexdev.mfxeffects.animations.base.Curve;
import io.github.palexdev.mfxeffects.animations.motion.M3Motion;
import io.github.palexdev.virtualizedfx.controls.VFXScrollBar;
import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.animation.PauseTransition;
import javafx.animation.Timeline;
import javafx.css.PseudoClass;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.PickResult;
import javafx.scene.input.ScrollEvent;
import javafx.util.Duration;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.function.Consumer;
import static io.github.palexdev.virtualizedfx.controls.VFXScrollBar.*;
/**
* Extension of {@link BehaviorBase} and default behavior implementation for {@link VFXScrollBar}.
*
* This offers all the methods to manage scrolling and smooth scrolling, track press/release,
* buttons press/release, thumb press/drag/release. And a bunch of other misc methods.
*/
public class VFXScrollBarBehavior extends BehaviorBase {
//================================================================================
// Properties
//================================================================================
private double dragStart;
// Animations config
protected Duration FIRST_TICK_DURATION = M3Motion.SHORT1;
protected Interpolator FIRST_TICK_CURVE = M3Motion.STANDARD;
protected Duration SMOOTH_SCROLL_DURATION = M3Motion.LONG2;
protected Duration TRACK_SMOOTH_SCROLL_DURATION = M3Motion.EXTRA_LONG1;
protected Interpolator SMOOTH_SCROLL_CURVE = Curve.EASE_BOTH;
protected Duration MAX_BUTTONS_SCROLL_DURATION = Duration.millis(3000);
protected Duration HOLD_DELAY = M3Motion.SHORT4;
// Animations state
private Animation holdAnimation;
private Animation scrollAnimation;
private final Set smoothScrollAnimations = new LinkedHashSet<>();
//================================================================================
// Constructors
//================================================================================
public VFXScrollBarBehavior(VFXScrollBar bar) {
super(bar);
}
//================================================================================
// Methods
//================================================================================
// THUMB
/**
* Action performed when the thumb has been pressed.
*
* The first steps are to stop any animation (see {@link #stopAnimations()}) then {@link #requestFocus()}
* and update the dragStart position using {@link #getMousePos(MouseEvent)}, in case the next user action is
* dragging the thumb.
*/
public void thumbPressed(MouseEvent me) {
stopAnimations();
requestFocus();
dragStart = getMousePos(me);
}
/**
* Action performed when the thumb is being dragged.
*
* First we call {@link #onDragging(boolean)}. Then we acquire the mouse position with {@link #getMousePos(MouseEvent)}
* to compute the traveled distance since the press event as {@code currentPos - dragStart}.
*
* We convert the traveled distance to the corresponding delta value by using {@link NumberUtils#mapOneRangeToAnother(double, DoubleRange, DoubleRange)}.
* A call to {@link #getAndSetScrollDirection(boolean)} updates the {@link VFXScrollBar#scrollDirectionProperty()}
* and returns a multiplier (1 or -1) used to adjust the scroll bar's value by the found delta value:
* {@code bar.setValue(bar.getValue() + deltaVal * mul)}.
*/
public void thumbDragged(MouseEvent me) {
VFXScrollBar bar = getNode();
onDragging(true);
double pos = getMousePos(me);
double deltaPos = pos - dragStart;
double maxPos = (bar.getOrientation() == Orientation.VERTICAL) ? bar.getHeight() : bar.getWidth();
// This is not accurate in theory as the max bound would be trackLength - thumbLength
// Works just fine so not gonna bother
double deltaVal = NumberUtils.mapOneRangeToAnother(
NumberUtils.clamp(Math.abs(deltaPos), 0.0, maxPos),
DoubleRange.of(0.0, maxPos),
DoubleRange.of(0.0, bar.getMaxScroll())
);
int mul = getAndSetScrollDirection(deltaPos > 0);
bar.setValue(bar.getValue() + deltaVal * mul);
}
/**
* Action performed when the thumb is released.
*
* The dragStart position is reset to 0.0, and the dragging property set to false.
*/
public void thumbReleased(MouseEvent me) {
dragStart = 0.0;
onDragging(false);
}
// TRACK
/**
* Action performed when the track is pressed.
*
* The first steps are to stop any animation (see {@link #stopAnimations()}) then {@link #requestFocus()}.
* Then we check whether smooth scroll and track smooth scroll features have been enabled, in such case the method
* exists as the scroll will be handled by {@link #trackReleased(MouseEvent)}.
*
* Otherwise, we get the mouse position with {@link #getMousePos(MouseEvent)} and the track length with {@link #getTrackLength(MouseEvent)}.
* We define the pos percentage as {@code mousePos / trackLength}. The target value is given by {@code maxScroll * posPercentage}.
* The delta value is {@code targetVal - bar.getValue()}. Finally, the scroll direction is determined by {@link #getAndSetScrollDirection(boolean)}.
*
* At this point, two animations are built:
*
* The first animation is responsible for the "first tick". When you press the track you expect the thumb to move
* towards the mouse position by the specified {@link VFXScrollBar#trackIncrementProperty()}. Then we must also
* consider what happens when the value has been adjusted, but the mouse is still pressed. Here's when the second animation
* comes into play. This animation basically detects if the mouse is still pressed (the delay is specified by {@link #HOLD_DELAY})
* and makes the thumb reposition towards the mouse position with a {@link MomentumTransition}. Before doing so, of course,
* it checks if the thumb is already at the mouse position or beyond, in such cases it simply does nothing.
*
* Both the second animation and the inner {@link MomentumTransition} are assigned to variables so that
* if any event occurs in the meanwhile which requires the animations to stop it can be done.
*/
public void trackPressed(MouseEvent me) {
stopAnimations();
requestFocus();
VFXScrollBar bar = getNode();
// If smooth scroll and track smooth scroll are active do not proceed.
// Such case is managed on track release
if (bar.isSmoothScroll() && bar.isTrackSmoothScroll()) return;
double pos = getMousePos(me);
double trackLength = getTrackLength(me);
double posPercentage = pos / trackLength;
double targetVal = bar.getMaxScroll() * posPercentage;
double deltaVal = targetVal - bar.getValue();
int mul = getAndSetScrollDirection(targetVal > bar.getValue());
// First scroll tick
Animation firstTick = firstTick(bar.getValue() + bar.getTrackIncrement() * mul);
// Detect hold
holdAnimation = onHold(e -> {
boolean isHover = bar.isHover();
boolean incAndGreater = (mul == 1) && bar.getValue() >= targetVal;
boolean decAndLesser = (mul != 1) && bar.getValue() <= targetVal;
boolean isMax = bar.getValue() == bar.getMax();
if (!isHover || incAndGreater || decAndLesser || isMax) return;
scrollAnimation = withMomentum(deltaVal, TRACK_SMOOTH_SCROLL_DURATION)
.setOnUpdate(u -> {
if (!bar.isHover()) {
stopAnimations();
return;
}
double val = bar.getValue() + u;
double clamped = (mul == 1) ? Math.min(val, targetVal) : Math.max(val, targetVal);
bar.setValue(clamped);
});
scrollAnimation.play();
});
firstTick.play();
holdAnimation.play();
}
/**
* Action performed when the track is released
*
* The first step is to stop any animation (see {@link #stopAnimations()}).
*
* The rest of the method executes only if both the smooth scroll and track smooth scroll features are enabled.
*
* As usual,we get the mouse position with {@link #getMousePos(MouseEvent)} and the track length with {@link #getTrackLength(MouseEvent)}.
* We define the pos percentage as {@code mousePos / trackLength}. The target value is given by {@code maxScroll * posPercentage}.
* The delta value is {@code targetVal - bar.getValue()}. Finally, the scroll direction is determined by {@link #getAndSetScrollDirection(boolean)}.
*
* Finally, the scroll bar's value is adjusted with a {@link MomentumTransition}.
*/
public void trackReleased(MouseEvent me) {
stopAnimations();
VFXScrollBar bar = getNode();
if (!bar.isHover()) return;
if (bar.isSmoothScroll() && bar.isTrackSmoothScroll()) {
double position = getMousePos(me);
double trackLength = getTrackLength(me);
double posPercentage = position / trackLength;
double targetVal = bar.getMaxScroll() * posPercentage;
double deltaVal = targetVal - bar.getValue();
int mul = getAndSetScrollDirection(targetVal > bar.getValue());
boolean increment = mul == 1;
scrollAnimation = withMomentum(deltaVal, TRACK_SMOOTH_SCROLL_DURATION)
.setOnUpdate(u -> {
double val = bar.getValue() + u;
double clamped = increment ? Math.min(val, targetVal) : Math.max(val, targetVal);
bar.setValue(clamped);
});
scrollAnimation.play();
}
}
/**
* Retrieves the scroll bar's track length from the given {@link MouseEvent}. This can be done on events intercepted
* by the track because we get the node thanks to {@link PickResult#getIntersectedNode()}.
* The returned value depends on the orientation.
*/
protected double getTrackLength(MouseEvent me) {
Orientation o = getNode().getOrientation();
Node track = me.getPickResult().getIntersectedNode();
Bounds bounds = track.getLayoutBounds();
return (o == Orientation.VERTICAL) ? bounds.getHeight() : bounds.getWidth();
}
// BUTTONS
/**
* Action performed when the decrease button is pressed.
*
* The first step is to stop any animation (see {@link #stopAnimations()}) and to acquire focus with {@link #requestFocus()}.
*
* Two animations are built:
*
* The first animation is responsible for the "first tick". When you press the button you expect the thumb to move
* up/left (depending on the orientation) by the specified {@link VFXScrollBar#unitIncrementProperty()}. Then we must also
* consider what happens when the value has been adjusted, but the mouse is still pressed. Here's when the second animation
* comes into play. This animation basically detects if the mouse is still pressed (the delay is specified by {@link #HOLD_DELAY})
* and makes the thumb reposition with a {@link Timeline}.
*
* Both the second animation and the inner {@link Timeline} are assigned to variables so that
* if any event occurs in the meanwhile which requires the animations to stop it can be done.
*
* A call to {@link #getAndSetScrollDirection(boolean)} with {@code false} as parameter ensures that the
* {@link VFXScrollBar#scrollDirectionProperty()} is updated.
*/
public void decreasePressed(MouseEvent me) {
stopAnimations();
requestFocus();
VFXScrollBar bar = getNode();
// First scroll tick
Animation firstTick = firstTick(bar.getValue() - bar.getUnitIncrement());
// Detect hold
holdAnimation = onHold(e -> {
double range = Math.min(bar.getMax(), bar.getMaxScroll()) - bar.getMin();
double speed = range / MAX_BUTTONS_SCROLL_DURATION.toMillis();
double deltaVal = bar.getValue() - bar.getMin();
double duration = deltaVal / speed;
scrollAnimation = TimelineBuilder.build()
.add(KeyFrames.of(duration, bar.valueProperty(), bar.getMin()))
.getAnimation();
scrollAnimation.play();
});
getAndSetScrollDirection(false);
firstTick.play();
holdAnimation.play();
}
/**
* Action performed when the decrease button is released.
*
* Simply calls {@link #stopAnimations()}.
*/
public void decreaseReleased(MouseEvent me) {
stopAnimations();
}
/**
* Action performed when the increase button is pressed.
*
* The first step is to stop any animation (see {@link #stopAnimations()}) and to acquire focus with {@link #requestFocus()}.
*
* Two animations are built:
*
* The first animation is responsible for the "first tick". When you press the button you expect the thumb to move
* down/right (depending on the orientation) by the specified {@link VFXScrollBar#unitIncrementProperty()}. Then we must also
* consider what happens when the value has been adjusted, but the mouse is still pressed. Here's when the second animation
* comes into play. This animation basically detects if the mouse is still pressed (the delay is specified by {@link #HOLD_DELAY})
* and makes the thumb reposition with a {@link Timeline}.
*
* Both the second animation and the inner {@link Timeline} are assigned to variables so that
* if any event occurs in the meanwhile which requires the animations to stop it can be done.
*
* A call to {@link #getAndSetScrollDirection(boolean)} with {@code true} as parameter ensures that the
* {@link VFXScrollBar#scrollDirectionProperty()} is updated.
*/
public void increasePressed(MouseEvent me) {
stopAnimations();
requestFocus();
VFXScrollBar bar = getNode();
// First scroll tick
Animation firstTick = firstTick(bar.getValue() + bar.getUnitIncrement());
// Detect hold
holdAnimation = onHold(e -> {
double max = Math.min(bar.getMax(), bar.getMaxScroll());
double range = max - bar.getMin();
double speed = range / MAX_BUTTONS_SCROLL_DURATION.toMillis();
double deltaVal = max - bar.getValue();
double duration = deltaVal / speed;
scrollAnimation = TimelineBuilder.build()
.add(KeyFrames.of(duration, bar.valueProperty(), max))
.getAnimation();
scrollAnimation.play();
});
getAndSetScrollDirection(true);
firstTick.play();
holdAnimation.play();
}
/**
* Action performed when the increment button is released.
*
* Simply calls {@link #stopAnimations()}.
*/
public void increaseReleased(MouseEvent me) {
stopAnimations();
}
// MISC
/**
* This method is responsible for switching the orientation {@link PseudoClass} on the scroll bar when the
* {@link VFXScrollBar#orientationProperty()} changes.
*
* The given callback is executed at the end and gives the new orientation as input.
*/
public void onOrientationChanged(Consumer callback) {
VFXScrollBar bar = getNode();
Orientation o = bar.getOrientation();
if (o == Orientation.VERTICAL) {
bar.pseudoClassStateChanged(HORIZONTAL_PSEUDO_CLASS, false);
bar.pseudoClassStateChanged(VERTICAL_PSEUDO_CLASS, true);
} else {
bar.pseudoClassStateChanged(VERTICAL_PSEUDO_CLASS, false);
bar.pseudoClassStateChanged(HORIZONTAL_PSEUDO_CLASS, true);
}
callback.accept(o);
}
/**
* Simply enables or disabled the ":dragging" {@link PseudoClass} on the scroll bar depending on the given parameter.
*/
protected void onDragging(boolean dragging) {
getNode().pseudoClassStateChanged(DRAGGING_PSEUDO_CLASS, dragging);
}
/**
* Stops any currently playing animation, including smooth scroll animations,
* hold animation (those responsible for detecting mouse press and hold), and any other
* scroll animation (typically the ones created inside hold animations)
*/
protected void stopAnimations() {
if (!smoothScrollAnimations.isEmpty()) {
smoothScrollAnimations.forEach(Animation::stop);
smoothScrollAnimations.clear();
}
if (holdAnimation != null) {
holdAnimation.stop();
holdAnimation = null;
}
if (scrollAnimation != null) {
scrollAnimation.stop();
scrollAnimation = null;
}
}
/**
* Obtains the mouse position from the given {@link MouseEvent} depending on
* the scroll bar's orientation.
*/
protected double getMousePos(MouseEvent me) {
return (getNode().getOrientation() == Orientation.VERTICAL) ?
me.getY() :
me.getX();
}
/**
* Obtains the scroll delta from the given {@link ScrollEvent} depending on
* the scroll bar's orientation.
*/
protected double getScrollDelta(ScrollEvent se) {
double delta = (getNode().getOrientation() == Orientation.VERTICAL) ?
se.getDeltaY() :
se.getDeltaX();
return (delta != 0) ? delta : (se.getDeltaY() != 0) ? se.getDeltaY() : se.getDeltaX();
}
protected int getAndSetScrollDirection(boolean incrementing) {
VFXScrollBar bar = getNode();
Orientation o = bar.getOrientation();
int mul = incrementing ? 1 : -1;
ScrollUtils.ScrollDirection sd = (o == Orientation.VERTICAL) ?
(mul == 1) ? ScrollUtils.ScrollDirection.DOWN : ScrollUtils.ScrollDirection.UP :
(mul == 1) ? ScrollUtils.ScrollDirection.RIGHT : ScrollUtils.ScrollDirection.LEFT;
bar.setScrollDirection(sd);
return mul;
}
/**
* Requests focus for the scroll bar if it's not already focused and if it's focus traversable.
*/
protected void requestFocus() {
VFXScrollBar bar = getNode();
if (!bar.isFocused() && bar.isFocusTraversable()) bar.requestFocus();
}
/**
* Convenience method to build a {@link Timeline} animation for the "first tick".
* Uses {@link #FIRST_TICK_DURATION} and {@link #FIRST_TICK_CURVE} and moves the scroll bar's value towards the given
* target value.
*/
protected Animation firstTick(double targetVal) {
return TimelineBuilder.build()
.add(KeyFrames.of(
FIRST_TICK_DURATION,
getNode().valueProperty(),
targetVal,
FIRST_TICK_CURVE
))
.getAnimation();
}
/**
* Convenience method to build a {@link PauseTransition} used to detect "mouse hold" events. The duration is set to
* {@link #HOLD_DELAY} and the given handler determines what happens when the animation ends.
*/
protected Animation onHold(EventHandler handler) {
return PauseBuilder.build()
.setDuration(HOLD_DELAY)
.setOnFinished(handler)
.getAnimation();
}
/**
* Convenience method to build a {@link MomentumTransition} using {@link MomentumTransition#fromTime(double, double)}
* with the two given parameters. Uses {@link #SMOOTH_SCROLL_CURVE} as the interpolator.
*/
protected MomentumTransition withMomentum(double delta, Duration duration) {
return (MomentumTransition) MomentumTransition.fromTime(delta, duration.toMillis())
.setInterpolatorFluent(SMOOTH_SCROLL_CURVE);
}
//================================================================================
// Overridden Methods
//================================================================================
/**
* Action performed when a {@link ScrollEvent} occurs.
*
* First the scroll delta is computed with {@link #getScrollDelta(ScrollEvent)} and in case it's 0
* it immediately exits.
*
* Then we determine the scroll direction with {@link #getAndSetScrollDirection(boolean)} and depending on the
* {@link VFXScrollBar#smoothScrollProperty()} the scroll value is adjuster either by a {@link MomentumTransition} or
* by the setter.
*/
@Override
public void scroll(ScrollEvent se, Consumer callback) {
VFXScrollBar bar = getNode();
double delta = getScrollDelta(se);
if (delta == 0) return;
// Determine scroll direction
// Delta > 0: Decreasing
// Delta < 0: Increasing
int mul = getAndSetScrollDirection(delta < 0);
if (bar.isSmoothScroll()) {
double deltaVal = bar.getValue() - (bar.getValue() + bar.getUnitIncrement() * -mul);
Animation mt = withMomentum(deltaVal, SMOOTH_SCROLL_DURATION)
.setOnUpdate(u -> bar.setValue(bar.getValue() + u));
mt.setOnFinished(e -> smoothScrollAnimations.remove(mt));
smoothScrollAnimations.add(mt);
mt.play();
if (callback != null) callback.accept(se);
return;
}
bar.setValue(bar.getValue() + bar.getUnitIncrement() * mul);
}
@Override
public void dispose() {
smoothScrollAnimations.clear();
super.dispose();
}
}