Javafx slider value at mousemoved event

2019-02-25 11:03发布

问题:

I am making a media player and am trying to get the playback slider value at the cursor position when hovering over the slider bar. In an attempt to do this, i have used the following:

    timeSlider.addEventFilter(MouseEvent.MOUSE_MOVED, event -> System.out.println("hovering"));

which prints "hovering" whenever the mouse changes position over the slider. Can anyone please show me how to get the value of the slider at the current cursor position? I can only figure out how to get the value at the thumb position.

Thanks in advance.

回答1:

Here is a bit (maybe more than a bit) of a hack that works if you are showing the axis under the slider. It relies on looking up the axis via its css class, converting the mouse coordinates to coordinates relative to the axis, and then using API from ValueAxis to convert to the value:

import javafx.application.Application;
import javafx.geometry.Point2D;
import javafx.scene.Scene;
import javafx.scene.chart.NumberAxis;
import javafx.scene.control.Label;
import javafx.scene.control.Slider;
import javafx.scene.layout.StackPane;
import javafx.stage.Popup;
import javafx.stage.Stage;

public class TooltipOnSlider extends Application {

    @Override
    public void start(Stage primaryStage) {
        Slider slider = new Slider(5, 25, 15);
        slider.setShowTickMarks(true);
        slider.setShowTickLabels(true);
        slider.setMajorTickUnit(5);

        Label label = new Label();
        Popup popup = new Popup();
        popup.getContent().add(label);

        double offset = 10 ;

        slider.setOnMouseMoved(e -> {
            NumberAxis axis = (NumberAxis) slider.lookup(".axis");
            Point2D locationInAxis = axis.sceneToLocal(e.getSceneX(), e.getSceneY());
            double mouseX = locationInAxis.getX() ;
            double value = axis.getValueForDisplay(mouseX).doubleValue() ;
            if (value >= slider.getMin() && value <= slider.getMax()) {
                label.setText(String.format("Value: %.1f", value));
            } else {
                label.setText("Value: ---");
            }
            popup.setAnchorX(e.getScreenX());
            popup.setAnchorY(e.getScreenY() + offset);
        });

        slider.setOnMouseEntered(e -> popup.show(slider, e.getScreenX(), e.getScreenY() + offset));
        slider.setOnMouseExited(e -> popup.hide());

        StackPane root = new StackPane(slider);
        primaryStage.setScene(new Scene(root, 350, 80));
        primaryStage.show();

    }

    public static void main(String[] args) {
        launch(args);
    }
}


回答2:

This is mostly a bug-track-down: James's answer is perfect - only hampered by 2 issues:

  1. the axis has to be visible, that is at least one of ticks or labels must be showing (in practice not a big obstacle: if you want to get the values at mouseOver you'r most probably showing the ticks anyway)

  2. A bug in SliderSkin which introduce a slight skew of axis value vs slider value.

To see the latter, here's a slight variation of James's code. To see the asynchronicity, move the mouse over the slider then click. We expect the value of the popup to be the same as the value of the slider (shown in the label at the bottom). With core SliderSkin, they differ slightly.

public class TooltipOnSlider extends Application {

    private boolean useAxis;
    @Override
    public void start(Stage primaryStage) {
        Slider slider = new Slider(5, 25, 15);
        useAxis = true;
        // force an axis to be used
        slider.setShowTickMarks(true);
        slider.setShowTickLabels(true);
        slider.setMajorTickUnit(5);

        // slider.setOrientation(Orientation.VERTICAL);
        // hacking around the bugs in a custom skin
        //  slider.setSkin(new MySliderSkin(slider));
        //  slider.setSkin(new XSliderSkin(slider));

        Label label = new Label();
        Popup popup = new Popup();
        popup.getContent().add(label);

        double offset = 30 ;

        slider.setOnMouseMoved(e -> {
            NumberAxis axis = (NumberAxis) slider.lookup(".axis");
            StackPane track = (StackPane) slider.lookup(".track");
            StackPane thumb = (StackPane) slider.lookup(".thumb");
            if (useAxis) {
                // James: use axis to convert value/position
                Point2D locationInAxis = axis.sceneToLocal(e.getSceneX(), e.getSceneY());
                boolean isHorizontal = slider.getOrientation() == Orientation.HORIZONTAL;
                double mouseX = isHorizontal ? locationInAxis.getX() : locationInAxis.getY() ;
                double value = axis.getValueForDisplay(mouseX).doubleValue() ;
                if (value >= slider.getMin() && value <= slider.getMax()) {
                    label.setText("" + value);
                } else {
                    label.setText("Value: ---");
                }

            } else {
                // this can't work because we don't know the internals of the track
                Point2D locationInAxis = track.sceneToLocal(e.getSceneX(), e.getSceneY());
                double mouseX = locationInAxis.getX();
                double trackLength = track.getWidth();
                double percent = mouseX / trackLength;
                double value = slider.getMin() + ((slider.getMax() - slider.getMin()) * percent);
                if (value >= slider.getMin() && value <= slider.getMax()) {
                    label.setText("" + value);
                } else {
                    label.setText("Value: ---");
                }
            }
            popup.setAnchorX(e.getScreenX());
            popup.setAnchorY(e.getScreenY() + offset);
        });

        slider.setOnMouseEntered(e -> popup.show(slider, e.getScreenX(), e.getScreenY() + offset));
        slider.setOnMouseExited(e -> popup.hide());

        Label valueLabel = new Label("empty");
        valueLabel.textProperty().bind(slider.valueProperty().asString());
        BorderPane root = new BorderPane(slider);
        root.setBottom(valueLabel);
        primaryStage.setScene(new Scene(root, 350, 100));
        primaryStage.show();
        primaryStage.setTitle("useAxis: " + useAxis + " mySkin: " + slider.getSkin().getClass().getSimpleName());
    }

    public static void main(String[] args) {
        launch(args);
    }

    @SuppressWarnings("unused")
    private static final Logger LOG = Logger.getLogger(TooltipOnSlider.class
            .getName());
}

Note that there's an open issue which reports a similar behavior (though not so easy to see)

Looking into the code of SliderSkin, the culprit seems to be an incorrect calculation of the relative value from a mouse event on the track:

track.setOnMousePressed(me -> {
    ... 
    double relPosition = (me.getX() / trackLength);
    getBehavior().trackPress(me, relPosition);
    ...
});

where track is positioned in the slider as:

// layout track 
track.resizeRelocate((int)(trackStart - trackRadius),
                     trackTop ,
                     (int)(trackLength + trackRadius + trackRadius),
                     trackHeight);

Note that the active width (aka: trackLenght) of the track is offset by trackRadius, thus calculating the relative distance with the raw mousePosition on the track gives a slight error.

Below is a crude custom skin that replaces the calc simply as a test if the little application behaves as expected. Looks terrible due the need to use reflection to access super's fields/methods but now has slider and axis value in synch.

The quick hack:

/**
 * Trying to work around down to the slight offset.
 */
public static class MySliderSkin extends SliderSkin {

    /**
     * Hook for replacing the mouse pressed handler that's installed by super.
     */
    protected void installListeners() {
        StackPane track = (StackPane) getSkinnable().lookup(".track");
        track.setOnMousePressed(me -> {
            invokeSetField("trackClicked", true);
            double trackLength = invokeGetField("trackLength");
            double trackStart = invokeGetField("trackStart");
            // convert coordinates into slider
            MouseEvent e = me.copyFor(getSkinnable(), getSkinnable());
            double mouseX = e.getX(); 
            double position;
            if (mouseX < trackStart) {
                position = 0;
            } else if (mouseX > trackStart + trackLength) {
                position = 1;
            } else {
               position = (mouseX - trackStart) / trackLength;
            }
            getBehavior().trackPress(e, position);
            invokeSetField("trackClicked", false);
        });
    }

    private double invokeGetField(String name) {
        Class clazz = SliderSkin.class;
        Field field;
        try {
            field = clazz.getDeclaredField(name);
            field.setAccessible(true);
            return field.getDouble(this);
        } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        return 0.;
    }

    private void invokeSetField(String name, Object value) {
        Class clazz = SliderSkin.class;
        try {
            Field field = clazz.getDeclaredField(name);
            field.setAccessible(true);
            field.set(this, value);
        } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
    /**
     * Constructor - replaces listener on track.
     * @param slider
     */
    public MySliderSkin(Slider slider) {
        super(slider);
        installListeners();
    }

}

A deeper fix might be to delegate all the dirty coordinate/value transformations to the axis - that's what it is designed to do. This requires the axis to be part of the scenegraph always and only toggle its visibilty with ticks/labels showing. A first experiment looks promising.