Skip to content
Advertisement

JavaFX event on Mouse Wheel Finished for ScrollPane

I have a ScrollPane with lots of elements on it, (Same one as this JavaFX setHgrow / binding property expanding infinitely) and initially I was planning on using the setOnScrollFinished(this::scrollFinished); event, however I’ve now discovered through research that this only applies to touch gestures, and trying to find a compromise for the MouseWheel hasn’t been great and I just find very complicated solutions which don’t really solve what I need.

The most I have is adding a listener to the scroll bar changing:

vvalueProperty().addListener(new ChangeListener<Number>() {
            @Override
            public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
                System.out.println("scroll time");
            }
        });

However this continuously fires while scrolling, what I’m looking for is something which will only call after, lets say, it’s been a second since I’ve stopped scrolling.

My ultimate goal is to have a system where when I scroll, it will run an event which will go through each of my elements so I can assign an image to them if they’re within the window bounds, and them remove the image if they’re not.

This is essentially my code, taken from the nice user who helped me out before:

import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;

public class ScrollPaneContentDemo extends Application {
    @Override
    public void start(Stage stage) throws Exception {
        List<Item> items = new ArrayList<>();
        IntStream.range(1, 1000).forEach(i -> items.add(new Item()));
        TestPanel root = new TestPanel(items);
        Scene scene = new Scene(root, 500, 500);
        stage.setScene(scene);
        stage.setTitle("ScrollPaneContent Demo");
        stage.show();
    }

    class TestPanel extends ScrollPane {
        private final int SPACING = 5;
        private final int ROW_MAX = 6;
        private DoubleProperty size = new SimpleDoubleProperty();

        public TestPanel(List<Item> items) {
            final VBox root = new VBox();
            root.setSpacing(SPACING);
            HBox row = null;
            int count = 0;
            for (Item item : items) {
                if (count == ROW_MAX || row == null) {
                    row = new HBox();
                    row.setSpacing(SPACING);
                    root.getChildren().add(row);
                    count = 0;
                }

                CustomBox box = new CustomBox(item);
                box.minWidthProperty().bind(size);
                row.getChildren().add(box);
                HBox.setHgrow(box, Priority.ALWAYS);
                count++;
            }
            setFitToWidth(true);
            setContent(root);

            double padding = 4;
            viewportBoundsProperty().addListener((obs, old, bounds) -> {
                size.setValue((bounds.getWidth() - padding - ((ROW_MAX - 1) * SPACING)) / ROW_MAX);
                });


        //setOnScroll(this::showImages);       //The problematic things

        vvalueProperty().addListener(new ChangeListener<Number>() {
        @Override
        public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
            System.out.println("scroll test");           //The problematic things
        }
    });

        }
    }

    class CustomBox extends StackPane {
        private Item item;
        private Rectangle square;
               private int size = 20;

    public CustomBox(Item item) {
        setStyle("-fx-background-color:#99999950;");
        this.item = item;
        setPadding(new Insets(5, 5, 5, 5));
        square = new Rectangle(size, size, Color.RED);
        square.widthProperty().bind(widthProperty());
        square.heightProperty().bind(heightProperty());

        maxHeightProperty().bind(minWidthProperty());
        maxWidthProperty().bind(minWidthProperty());
        minHeightProperty().bind(minWidthProperty());
        getChildren().add(square);
    }
}

    class Item {
    }
}

Advertisement

Answer

You will have to listen for property changes to detect scrolling without missing. You don’t have to take heavy action each time the listener triggers though: just record the time when it happened, and then have a loop filter out and fire the event when needed. This goes:

  1. Register any time the scroll values change (or the ScrollPane is resized)
  2. Setup a loop that will check on short intervals (from a user perspective) if a change was registered more than 1 second ago.
  3. When this happens, have the ScrollPane fire an event – let’s call this a “tick”- and un-register last scroll

For the loop, we’ll use a Timeline which KeyFrames will have a onFinished handler called on the JavaFX application thread about every 100ms, in order to avoid having to deal with another thread.

class TickingScrollPane extends ScrollPane {

  //Our special event type, to be fired after a delay when scrolling stops
  public static final EventType<Event> SCROLL_TICK = new EventType<>(TickingScrollPane.class.getName() + ".SCROLL_TICK");

  // Strong refs to listener and timeline
  private final ChangeListener<? super Number> scrollListener; //Will register any scrolling
  private final Timeline notifyLoop;  //Will check every 100ms how long ago we last scrolled

  // Last registered scroll timing
  private long lastScroll = 0; // 0 means "no scroll registered"

  public TickingScrollPane() {
    super();

    /* Register any time a scrollbar moves (scrolling by any means or resizing)
     * /! will fire once when initially shown because of width/height listener */
    scrollListener = (_observable, _oldValue, _newValue) -> {
      lastScroll = System.currentTimeMillis();
    };
    this.vvalueProperty().addListener(scrollListener);
    this.hvalueProperty().addListener(scrollListener);
    this.widthProperty().addListener(scrollListener);
    this.heightProperty().addListener(scrollListener);
    //ScrollEvent.SCROLL works only for mouse wheel, but you could as well use it 

    /* Every 100ms, check if there's a registered scroll.
     * If so, and it's older than 1000ms, then fire and unregister it.
     * Will therefore fire at most once per second, about 1 second after scroll stopped */
    this.notifyLoop = new Timeline(new KeyFrame(Duration.millis(100), //100ms exec. interval
        e -> {
          if (lastScroll == 0)
            return;
          long now = System.currentTimeMillis();
          if (now - lastScroll > 1000) { //1000ms delay
            lastScroll = 0;
            fireEvent(new Event(this, this, SCROLL_TICK));
          }
        }));
    this.notifyLoop.setCycleCount(Timeline.INDEFINITE);
    this.notifyLoop.play();

  }

}

If your ScrollPane is to be removed from the scene at any point, you might want to add a method to stop the TimeLine to avoid it continuing to run and possibly consuming memory.

Full runnable demo code:

package application;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.event.Event;
import javafx.event.EventType;
import javafx.geometry.BoundingBox;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
import javafx.util.Duration;

class TickingScrollPane extends ScrollPane {

  //Our special event type, to be fired after a delay when scrolling stops
  public static final EventType<Event> SCROLL_TICK = new EventType<>(TickingScrollPane.class.getName() + ".SCROLL_TICK");

  // Strong refs to listener and timeline
  private final ChangeListener<? super Number> scrollListener; //Will register any scrolling
  private final Timeline notifyLoop;  //Will check every 100ms how long ago we last scrolled

  // Last registered scroll timing
  private long lastScroll = 0; // 0 means "no scroll registered"

  public TickingScrollPane() {
    super();

    /* Register any time a scrollbar moves (scrolling by any means or resizing)
     * /! will fire once when initially shown because of width/height listener */
    scrollListener = (_observable, _oldValue, _newValue) -> {
      lastScroll = System.currentTimeMillis();
    };
    this.vvalueProperty().addListener(scrollListener);
    this.hvalueProperty().addListener(scrollListener);
    this.widthProperty().addListener(scrollListener);
    this.heightProperty().addListener(scrollListener);
    //ScrollEvent.SCROLL works only for mouse wheel, but you could as well use it 

    /* Every 100ms, check if there's a registered scroll.
     * If so, and it's older than 1000ms, then fire and unregister it.
     * Will therefore fire at most once per second, about 1 second after scroll stopped */
    this.notifyLoop = new Timeline(new KeyFrame(Duration.millis(100), //100ms exec. interval
        e -> {
          if (lastScroll == 0)
            return;
          long now = System.currentTimeMillis();
          if (now - lastScroll > 1000) { //1000ms delay
            lastScroll = 0;
            fireEvent(new Event(this, this, SCROLL_TICK));
          }
        }));
    this.notifyLoop.setCycleCount(Timeline.INDEFINITE);
    this.notifyLoop.play();

  }

}

public class TickingScrollPaneTest extends Application {
  
  @Override
  public void start(Stage primaryStage) {
    
    try {
      
      //Draw our scrollpane, add a bunch of rectangles in a VBox to fill its contents
      TickingScrollPane root = new TickingScrollPane();
      root.setPadding(new Insets(5));
      VBox vb = new VBox(6);
      root.setContent(vb);
      final int rectsCount = 10;
      for (int i = 0; i < rectsCount; i++) {
        Rectangle r = new Rectangle(Math.random() * 900, 60); //Random width, 60px height
        r.setFill(Color.hsb(360. / rectsCount * i, 1, .85));   //Changing hue (rainbow style)
        vb.getChildren().add(r);
      }
      
      //Log every scroll tick to console
      root.addEventHandler(TickingScrollPane.SCROLL_TICK, e -> {
        System.out.println(String.format(
            "%s:tScrolled 1s ago to (%s)",
            LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME),
            getViewableBounds(root)
            ));
      });
      
      //Show in a 400x400 window
      Scene scene = new Scene(root, 400, 400);
      primaryStage.setScene(scene);
      primaryStage.setTitle("TickingScrollPane test");
      primaryStage.show();
    } catch (Exception e) {
      e.printStackTrace();
    }
    
  }
  
  
  /**
   * Calculate viewable bounds for contents for ScrollPane
   * given viewport size and scroll position
   */
  private static Bounds getViewableBounds(ScrollPane scrollPane) {
    Bounds vbds = scrollPane.getViewportBounds();
    Bounds cbds = scrollPane.getContent().getLayoutBounds();
    double hoffset = 0;
    if (cbds.getWidth() > vbds.getWidth())
      hoffset = Math.max(0, cbds.getWidth() - vbds.getWidth()) * (scrollPane.getHvalue() - scrollPane.getHmin()) / (scrollPane.getHmax() - scrollPane.getHmin());
    double voffset = 0;
    if (cbds.getHeight() > vbds.getHeight())
      voffset = Math.max(0, cbds.getHeight() - vbds.getHeight()) * (scrollPane.getVvalue() - scrollPane.getVmin()) / (scrollPane.getVmax() - scrollPane.getVmin());
    Bounds viewBounds = new BoundingBox(hoffset, voffset, vbds.getWidth(), vbds.getHeight());
    return viewBounds;
  }

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

7 People found this is helpful
Advertisement