FXMisc / Flowless

Efficient VirtualFlow for JavaFX
BSD 2-Clause "Simplified" License
185 stars 38 forks source link

Smooth scrolling #27

Closed mikehearn closed 3 years ago

mikehearn commented 8 years ago

VirtualFlow calculates a reasonable jump size when using the mouse wheel or scroll bar increment/decrement buttons. This leads to reasonable scrolling behaviour but with large content (e.g. a many page PDF) the scroll amount can be quite large, like 20 pixels or so, and this leads to a jerky feeling when scrolling.

Seeing as how ReactFX has the nice Val.animate() method it would probably be quite easy to fix this. I had a quick go at doing so without hacking Flowless itself but it seems the methods I'd need are all (package) private. Perhaps there's a simple trick to get this; I guess inserting a simple animated val with short duration between the desired scroll position and the actual position is sufficient.

JordanMartinez commented 7 years ago

Now that I've documented the code in #33, I don't know if your Val.animate idea is feasible. In my understanding, the viewport lays out its content first, and then checks whether there is unused space. If there is, it may relayout that content a second time. Since the scroll values are estimates calculated from the averages of the displayed cells' nodes, I'd guess that these jerky value changes are a result of these averages being recalculated when the displayed nodes are repositioned or nodes are added/removed.

Instead, I'd suggest the code does something similar to the RichTexfFX approach of suspending a value when the view is being updated and resuming it when its finished.

JordanMartinez commented 7 years ago

Can you give a standalone test that demonstrates this jerkiness? I'm assuming the solution would be to wrap the layoutChildren and any show()-related methods in a Suspendables.combine(estimatedScrollX, estimatedScrollY).suspendWhile() block

palexdev commented 3 years ago

Still no support for smooth scrolling? I tried to make it work with the same strategy I use for JavaFX's scroll panes, see here: MaterialFX ScrollPane, but it won't work

Jugen commented 3 years ago

What happens when you try your method on a VirtualFlow ? Can you provide a demo with both a ScrollPane and a VirtualFlow with the same content that show/compares the behavior you want ?

palexdev commented 3 years ago

https://user-images.githubusercontent.com/16880178/111906033-fe180000-8a4e-11eb-952f-c7dae1759dd7.mp4

As you can see the handler in never executed with the VirtualizedScrollPane. I had to modify the code a bit though because VirtualizedScrollPane doesn't have vvalue and hvalue properties. I had to use reflection and change the smooth scroll code to:

    private static void customScrolling(MFXVirtualizedScrollPane<?> scrollPane, DoubleProperty scrollDirection, Function<Bounds, Double> sizeFunc) {
        final double[] frictions = {0.99, 0.1, 0.05, 0.04, 0.03, 0.02, 0.01, 0.04, 0.01, 0.008, 0.008, 0.008, 0.008, 0.0006, 0.0005, 0.00003, 0.00001};
        final double[] pushes = {1};
        final double[] derivatives = new double[frictions.length];

        Timeline timeline = new Timeline();
        final EventHandler<MouseEvent> dragHandler = event -> timeline.stop();
        final EventHandler<ScrollEvent> scrollHandler = event -> {
            System.out.println("Smooth Scrolling");
            if (event.getEventType() == ScrollEvent.SCROLL) {
                int direction = event.getDeltaY() > 0 ? -1 : 1;
                for (int i = 0; i < pushes.length; i++) {
                    derivatives[i] += direction * pushes[i];
                }
                if (timeline.getStatus() == Animation.Status.STOPPED) {
                    timeline.play();
                }
                event.consume();
            }
        };
        if (scrollPane.getContent().getParent() != null) {
            scrollPane.getContent().getParent().addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
            scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler);
        }
        scrollPane.getContent().parentProperty().addListener((observable, oldValue, newValue) -> {
            if (oldValue != null) {
                oldValue.removeEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
                oldValue.removeEventHandler(ScrollEvent.ANY, scrollHandler);
            }
            if (newValue != null) {
                newValue.addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
                newValue.addEventHandler(ScrollEvent.ANY, scrollHandler);
            }
        });
        timeline.getKeyFrames().add(new KeyFrame(Duration.millis(3), (event) -> {
            for (int i = 0; i < derivatives.length; i++) {
                derivatives[i] *= frictions[i];
            }
            for (int i = 1; i < derivatives.length; i++) {
                derivatives[i] += derivatives[i - 1];
            }
            double dy = derivatives[derivatives.length - 1];
            double size = sizeFunc.apply(scrollPane.getContent().getLayoutBounds());
            scrollDirection.set(Math.min(Math.max(scrollDirection.get() + dy / size, 0), 1));
            if (Math.abs(dy) < 0.001) {
                timeline.stop();
            }
        }));
        timeline.setCycleCount(Animation.INDEFINITE);
    }

    public static void smoothVScrolling(MFXVirtualizedScrollPane<?> scrollPane) {
        try {
            Field vvalue = VirtualizedScrollPane.class.getDeclaredField("vBarValue");
            vvalue.setAccessible(true);
            Var<Double> obj = (Var<Double>) vvalue.get(scrollPane);
            DoubleProperty doubleProperty = new SimpleDoubleProperty();
            doubleProperty.bind(obj);
            customScrolling(scrollPane, doubleProperty, Bounds::getHeight);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
Jugen commented 3 years ago

Here you go, try this slightly modified version of the code you provided:

// Changed method signature to use VirtualFlow and its estimated scroll property directly
private static void customScrolling( VirtualFlow<?,?> flowPane, Var<Double> scrollDirection ) {
    final double[] frictions = {0.99, 0.1, 0.05, 0.04, 0.03, 0.02, 0.01, 0.04, 0.01, 0.008, 0.008, 0.008, 0.008, 0.0006, 0.0005, 0.00003, 0.00001};
    final double[] pushes = {1};
    final double[] derivatives = new double[frictions.length];

    Timeline timeline = new Timeline();
    final EventHandler<MouseEvent> dragHandler = event -> timeline.stop();
    final EventHandler<ScrollEvent> scrollHandler = event -> {
        if (event.getEventType() == ScrollEvent.SCROLL) {
            System.out.println("Smooth Scrolling");
            int direction = event.getDeltaY() > 0 ? -1 : 1;
            for (int i = 0; i < pushes.length; i++) {
                derivatives[i] += direction * pushes[i];
            }
            if (timeline.getStatus() == Animation.Status.STOPPED) {
                timeline.play();
            }
            event.consume();
        }
    };

    // Changed this to get ScrollEvents to work, "Smooth Scrolling" prints now :-)
    flowPane.addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler);
    flowPane.addEventHandler(ScrollEvent.ANY, scrollHandler);

    timeline.getKeyFrames().add(new KeyFrame(Duration.millis(3), (event) -> {
        for (int i = 0; i < derivatives.length; i++) {
            derivatives[i] *= frictions[i];
        }
        for (int i = 1; i < derivatives.length; i++) {
            derivatives[i] += derivatives[i - 1];
        }
        double dy = derivatives[derivatives.length - 1];

        // Changed this as values aren't between 0 & 1
        scrollDirection.setValue(scrollDirection.getValue() + dy);

        if (Math.abs(dy) < 0.001) {
            timeline.stop();
        }
    }));
    timeline.setCycleCount(Animation.INDEFINITE);
}
Jugen commented 3 years ago

Sorry the method signature can be reduced to:

private static void customScrolling( VirtualFlow<?,?> flowPane, Var<Double> scrollDirection )

which then is invoked with:

customScrolling( virtualFlow, virtualFlow.estimatedScrollYProperty() );

palexdev commented 3 years ago

@Jugen Hello, sorry for the late response. I can confirm that now the smooth scrolling works, however there still seems to be some issues: 1) The DRAG_DETECTED handler is not working. So if the view is scrolling, and you click on the scrollbar it doesn't stop scrolling. 2) There's some sort of "jump" when the scrolling starts which should not happen

https://user-images.githubusercontent.com/16880178/114301123-9d1a9f80-9ac3-11eb-84b0-37eaf30e7afe.mp4

EDIT: I was wondering if I could fix it by changing the type of handler... Yep, everything works now The code now is:

    public static void setSmoothScrolling(VirtualFlow<?, ?> flow, Var<Double> scrollDirection) {
        final double[] frictions = {0.99, 0.1, 0.05, 0.04, 0.03, 0.02, 0.01, 0.04, 0.01, 0.008, 0.008, 0.008, 0.008, 0.0006, 0.0005, 0.00003, 0.00001};
        final double[] pushes = {1};
        final double[] derivatives = new double[frictions.length];

        Timeline timeline = new Timeline();
        final EventHandler<MouseEvent> dragHandler = event -> {
            System.out.println("STOP!");
            timeline.stop();
        };

        final EventHandler<ScrollEvent> scrollHandler = event -> {
            if (event.getEventType() == ScrollEvent.SCROLL) {
                System.out.println("Smooth Scrolling");
                int direction = event.getDeltaY() > 0 ? -1 : 1;
                for (int i = 0; i < pushes.length; i++) {
                    derivatives[i] += direction * pushes[i];
                }
                if (timeline.getStatus() == Animation.Status.STOPPED) {
                    timeline.play();
                }
                event.consume();
            }
        };

        if (flow.getParent() != null) {
            flow.getParent().addEventFilter(MouseEvent.DRAG_DETECTED, dragHandler);
        }
       flow.parentProperty().addListener((observable, oldValue, newValue) -> {
            if (oldValue != null) {
                oldValue.removeEventFilter(MouseEvent.DRAG_DETECTED, dragHandler);
            }
            if (newValue != null) {
                newValue.addEventFilter(MouseEvent.DRAG_DETECTED, dragHandler);
            }
        });
        flow.addEventFilter(MouseEvent.DRAG_DETECTED, dragHandler);
        flow.addEventFilter(ScrollEvent.ANY, scrollHandler);

        timeline.getKeyFrames().add(new KeyFrame(Duration.millis(3), (event) -> {
            for (int i = 0; i < derivatives.length; i++) {
                derivatives[i] *= frictions[i];
            }
            for (int i = 1; i < derivatives.length; i++) {
                derivatives[i] += derivatives[i - 1];
            }
            double dy = derivatives[derivatives.length - 1];

            scrollDirection.setValue(scrollDirection.getValue() + dy);

            if (Math.abs(dy) < 0.001) {
                timeline.stop();
            }
        }));
        timeline.setCycleCount(Animation.INDEFINITE);
    }

I changed the handlers to filters and everything seems to work good. The only thing I'm going to change is making this method private and create a new public method that accepts a list view as a parameter because the virtual flow is in the skin class. This method will probably look like this:

    public static void setSmoothScrolling(AbstractMFXFlowlessListView<?, ?, ?> listView) {
        if (listView.getScene() != null) {
            VirtualFlow<?, ?> flow = (VirtualFlow<?, ?>) listView.lookup(".virtual-flow");
            setSmoothScrolling(flow, flow.estimatedScrollYProperty());
        } else {
            listView.skinProperty().addListener(new ChangeListener<>() {
                @Override
                public void changed(ObservableValue<? extends Skin<?>> observable, Skin<?> oldValue, Skin<?> newValue) {
                    if (newValue != null) {
                        VirtualFlow<?, ?> flow = (VirtualFlow<?, ?>) listView.lookup(".virtual-flow");
                        setSmoothScrolling(flow, flow.estimatedScrollYProperty());
                        listView.skinProperty().removeListener(this);
                    }
                }
            });
        }
    }