From febc14697fa58fd30f45375d89ff6284e37addf9 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 31 Jul 2026 21:44:45 +0800 Subject: [PATCH 1/3] Feat: Implement smooth scrolling for ScrollPane with pixel-based adjustments --- .../org/jackhuang/hmcl/ui/ScrollUtils.java | 537 +++++++++++++++--- .../jackhuang/hmcl/ui/ScrollUtilsTest.java | 65 +++ 2 files changed, 536 insertions(+), 66 deletions(-) create mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java index 2f52011b1bf..e70af67cea2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java @@ -24,13 +24,26 @@ import javafx.animation.Animation.Status; import javafx.animation.KeyFrame; import javafx.animation.Timeline; +import javafx.animation.Transition; import javafx.event.EventHandler; +import javafx.event.EventTarget; +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.ListView; import javafx.scene.control.ScrollPane; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TreeTableView; +import javafx.scene.control.TreeView; import javafx.scene.control.skin.VirtualFlow; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.Region; +import javafx.stage.Window; import javafx.util.Duration; -import org.jackhuang.hmcl.util.Holder; +import org.jackhuang.hmcl.ui.animation.Motion; +import org.jetbrains.annotations.Nullable; /** * Utility class for ScrollPanes. @@ -56,6 +69,15 @@ public int intDirection() { private static final double CUTOFF_DELTA = 0.01; + /// The property key for the smooth scroll state installed on a scroll pane. + private static final Object SCROLL_PANE_STATE_KEY = new Object(); + + /// The pixel distance used for one platform-reported text line or character. + private static final double DEFAULT_LINE_SCROLL_PIXELS = 40.0; + + /// The minimum meaningful difference between two scroll values. + private static final double EPSILON = 0.000001; + /** * Determines if the given ScrollEvent comes from a trackpad. *

@@ -115,25 +137,32 @@ public static void addSmoothScrolling(ScrollPane scrollPane) { addSmoothScrolling(scrollPane, DEFAULT_SPEED); } - /** - * Adds a smooth scrolling effect to the given scroll pane with the given scroll speed. - * Calls {@link #addSmoothScrolling(ScrollPane, double, double)} - * with a default trackPadAdjustment of 7. - */ + /// Adds smooth scrolling to a scroll pane using its platform-reported scroll distances. + /// + /// @param scrollPane the scroll pane to configure + /// @param speed the multiplier applied to platform scroll distances public static void addSmoothScrolling(ScrollPane scrollPane, double speed) { - addSmoothScrolling(scrollPane, speed, DEFAULT_TRACK_PAD_ADJUSTMENT); + addSmoothScrolling(scrollPane, speed, 1.0); } - /** - * Adds a smooth scrolling effect to the given scroll pane with the given - * scroll speed and the given trackPadAdjustment. - *

- * The trackPadAdjustment is a value used to slow down the scrolling if a trackpad is used. - * This is kind of a workaround and it's not perfect, but at least it's way better than before. - * The default value is 7, tested up to 10, further values can cause scrolling misbehavior. - */ + /// Adds smooth scrolling to a scroll pane with an optional adjustment for small pixel deltas. + /// + /// This overload is retained for callers that explicitly configured the former trackpad adjustment. Text-line + /// and page scroll units are never adjusted. + /// + /// @param scrollPane the scroll pane to configure + /// @param speed the multiplier applied to platform scroll distances + /// @param trackPadAdjustment the divisor applied to small pixel-unit deltas public static void addSmoothScrolling(ScrollPane scrollPane, double speed, double trackPadAdjustment) { - smoothScroll(scrollPane, speed, trackPadAdjustment); + @Nullable Object installedState = scrollPane.getProperties().get(SCROLL_PANE_STATE_KEY); + if (installedState instanceof ScrollPaneSmoothScrollState state) { + state.configure(speed, trackPadAdjustment); + } else { + scrollPane.getProperties().put( + SCROLL_PANE_STATE_KEY, + new ScrollPaneSmoothScrollState(scrollPane, speed, trackPadAdjustment) + ); + } } /// @author Glavo @@ -154,69 +183,445 @@ public static void addSmoothScrolling(VirtualFlow virtualFlow, double speed, private static 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}; private static final Duration DURATION = Duration.millis(3); - private static void smoothScroll(ScrollPane scrollPane, double speed, double trackPadAdjustment) { - final double[] derivatives = new double[FRICTIONS.length]; + /// Maintains the accumulated scroll target and transition for one scroll pane. + private static final class ScrollPaneSmoothScrollState { + /// The scroll pane receiving smooth scrolling. + private final ScrollPane scrollPane; + + /// The reusable transition for both axes. + private final ScrollPaneTransition animation; + + /// The event filter installed on the scroll pane. + private final EventHandler scrollHandler = this::handleScroll; + + /// Stops pending wheel motion before mouse-driven interaction. + private final EventHandler mouseHandler = event -> stopAnimation(); + + /// The accumulated horizontal target. + private double targetHValue; + + /// The accumulated vertical target. + private double targetVValue; + + /// The horizontal pixel span represented by the current target. + private double targetHScrollablePixels; + + /// The vertical pixel span represented by the current target. + private double targetVScrollablePixels; + + /// The configured scroll distance multiplier. + private double speed; + + /// The configured divisor for small pixel-unit deltas. + private double trackPadAdjustment; + + /// Creates and installs smooth scrolling for a scroll pane. + /// + /// @param scrollPane the scroll pane to configure + /// @param speed the scroll distance multiplier + /// @param trackPadAdjustment the small-delta divisor + private ScrollPaneSmoothScrollState( + ScrollPane scrollPane, + double speed, + double trackPadAdjustment + ) { + this.scrollPane = scrollPane; + this.animation = new ScrollPaneTransition(scrollPane); + this.targetHValue = scrollPane.getHvalue(); + this.targetVValue = scrollPane.getVvalue(); + configure(speed, trackPadAdjustment); + scrollPane.addEventFilter(ScrollEvent.SCROLL, scrollHandler); + scrollPane.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); + } - Timeline timeline = new Timeline(); - Holder scrollDirectionHolder = new Holder<>(); - final EventHandler mouseHandler = event -> timeline.stop(); - final EventHandler scrollHandler = event -> { - if (event.getEventType() == ScrollEvent.SCROLL) { - ScrollDirection scrollDirection = determineScrollDirection(event); - scrollDirectionHolder.value = scrollDirection; + /// Updates scroll distance settings without installing another event filter. + /// + /// @param speed the scroll distance multiplier + /// @param trackPadAdjustment the small-delta divisor + private void configure(double speed, double trackPadAdjustment) { + this.speed = speed; + this.trackPadAdjustment = trackPadAdjustment; + } - double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed; + /// Stops pending smooth movement. + private void stopAnimation() { + animation.stop(); + } - derivatives[0] += scrollDirection.intDirection * currentSpeed; - if (timeline.getStatus() == Status.STOPPED) { - timeline.play(); - } - event.consume(); + /// Handles one indirect wheel or trackpad event. + /// + /// @param event the scroll event delivered to the pane + private void handleScroll(ScrollEvent event) { + if (event.isDirect() || !isEventTargetForScrollPane(scrollPane, event.getTarget())) { + return; } - }; - if (scrollPane.getContent().getParent() != null) { - scrollPane.getContent().getParent().addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); - scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler); - } - scrollPane.getContent().parentProperty().addListener((observable, oldValue, newValue) -> { - if (oldValue != null) { - oldValue.removeEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); - oldValue.removeEventHandler(ScrollEvent.ANY, scrollHandler); + + double viewportWidth = scrollPane.getViewportBounds().getWidth(); + double viewportHeight = scrollPane.getViewportBounds().getHeight(); + double adjustment = scrollAdjustment(event); + double horizontalDelta = scrollDeltaX(event) * adjustment; + double verticalDelta = scrollDeltaY(event, viewportHeight) * adjustment; + double horizontalScrollablePixels = Math.max(0.0, contentWidth(scrollPane) - viewportWidth); + double verticalScrollablePixels = Math.max(0.0, contentHeight(scrollPane) - viewportHeight); + boolean canScrollHorizontally = canScroll( + scrollPane.getHmin(), + scrollPane.getHmax(), + horizontalScrollablePixels + ); + boolean canScrollVertically = canScroll( + scrollPane.getVmin(), + scrollPane.getVmax(), + verticalScrollablePixels + ); + + if (animation.getStatus() == Status.STOPPED) { + targetHValue = scrollPane.getHvalue(); + targetVValue = scrollPane.getVvalue(); + } else { + targetHValue = retargetScrollValue( + targetHValue, + targetHScrollablePixels, + horizontalScrollablePixels, + scrollPane.getHmin(), + scrollPane.getHmax() + ); + targetVValue = retargetScrollValue( + targetVValue, + targetVScrollablePixels, + verticalScrollablePixels, + scrollPane.getVmin(), + scrollPane.getVmax() + ); } - if (newValue != null) { - newValue.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); - newValue.addEventHandler(ScrollEvent.ANY, scrollHandler); + targetHScrollablePixels = horizontalScrollablePixels; + targetVScrollablePixels = verticalScrollablePixels; + + if (event.isShiftDown() && canScrollHorizontally && close(horizontalDelta, 0.0)) { + horizontalDelta = verticalDelta; + verticalDelta = 0.0; + } else if (!canScrollVertically + && canScrollHorizontally + && close(horizontalDelta, 0.0) + && !close(verticalDelta, 0.0)) { + horizontalDelta = verticalDelta; + verticalDelta = 0.0; } - }); - timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> { - for (int i = 0; i < derivatives.length; i++) { - derivatives[i] *= FRICTIONS[i]; + double nextHValue = targetHValue; + double nextVValue = targetVValue; + if (canScrollHorizontally && !close(horizontalDelta, 0.0)) { + nextHValue = scrollTargetValue( + targetHValue, + horizontalDelta, + scrollPane.getHmin(), + scrollPane.getHmax(), + horizontalScrollablePixels + ); } - for (int i = 1; i < derivatives.length; i++) { - derivatives[i] += derivatives[i - 1]; + if (canScrollVertically && !close(verticalDelta, 0.0)) { + nextVValue = scrollTargetValue( + targetVValue, + verticalDelta, + scrollPane.getVmin(), + scrollPane.getVmax(), + verticalScrollablePixels + ); } - double dy = derivatives[derivatives.length - 1]; - double size; - switch (scrollDirectionHolder.value) { - case LEFT: - case RIGHT: - size = scrollPane.getContent().getLayoutBounds().getWidth(); - scrollPane.setHvalue(Math.min(Math.max(scrollPane.getHvalue() + dy / size, 0), 1)); - break; - case UP: - case DOWN: - size = scrollPane.getContent().getLayoutBounds().getHeight(); - scrollPane.setVvalue(Math.min(Math.max(scrollPane.getVvalue() + dy / size, 0), 1)); - break; + if (close(nextHValue, targetHValue) && close(nextVValue, targetVValue)) { + return; } - if (Math.abs(dy) < CUTOFF_DELTA) { - timeline.stop(); + targetHValue = nextHValue; + targetVValue = nextVValue; + animateToTarget(); + event.consume(); + } + + /// Returns the multiplier for this event's platform unit type. + /// + /// @param event the event being handled + /// @return the configured speed, optionally divided for a small pixel delta + private double scrollAdjustment(ScrollEvent event) { + boolean usesPixelUnits = event.getTextDeltaXUnits() == ScrollEvent.HorizontalTextScrollUnits.NONE + && event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.NONE; + if (usesPixelUnits && isTrackPad(event, determineScrollDirection(event))) { + return speed / trackPadAdjustment; } - })); - timeline.setCycleCount(Animation.INDEFINITE); + return speed; + } + + /// Starts a transition or applies the target immediately when no window can render it. + private void animateToTarget() { + if (!canAnimate(scrollPane)) { + animation.stop(); + scrollPane.setHvalue(targetHValue); + scrollPane.setVvalue(targetVValue); + return; + } + + animation.configure( + scrollPane.getHvalue(), + targetHValue, + scrollPane.getVvalue(), + targetVValue + ); + animation.playFromStart(); + } + } + + /// Interpolates both scroll axes for one scroll pane. + private static final class ScrollPaneTransition extends Transition { + /// The scroll pane whose values are updated. + private final ScrollPane scrollPane; + + /// The horizontal value at the start of the current transition. + private double startHValue; + + /// The horizontal target of the current transition. + private double targetHValue; + + /// The vertical value at the start of the current transition. + private double startVValue; + + /// The vertical target of the current transition. + private double targetVValue; + + /// Creates a reusable transition for a scroll pane. + /// + /// @param scrollPane the pane whose values are updated + private ScrollPaneTransition(ScrollPane scrollPane) { + this.scrollPane = scrollPane; + setCycleDuration(Motion.LONG2); + setInterpolator(Motion.STANDARD_DECELERATE); + } + + /// Reconfigures this transition for the latest accumulated targets. + /// + /// @param startHValue the current horizontal value + /// @param targetHValue the horizontal target + /// @param startVValue the current vertical value + /// @param targetVValue the vertical target + private void configure( + double startHValue, + double targetHValue, + double startVValue, + double targetVValue + ) { + stop(); + this.startHValue = startHValue; + this.targetHValue = targetHValue; + this.startVValue = startVValue; + this.targetVValue = targetVValue; + } + + /// Applies an eased animation fraction to both axes. + /// + /// @param fraction the eased fraction + @Override + protected void interpolate(double fraction) { + scrollPane.setHvalue(ScrollUtils.interpolate(startHValue, targetHValue, fraction)); + scrollPane.setVvalue(ScrollUtils.interpolate(startVValue, targetVValue, fraction)); + } + } + + /// Returns whether the event target belongs to this pane rather than to a nested scroll owner. + /// + /// @param scrollPane the pane that installed the event filter + /// @param target the original event target + /// @return `true` when this pane owns the event + static boolean isEventTargetForScrollPane(ScrollPane scrollPane, EventTarget target) { + if (!(target instanceof Node node)) { + return true; + } + + @Nullable Node current = node; + while (current != null && current != scrollPane) { + if (current instanceof ScrollPane + || current instanceof TextArea + || current instanceof VirtualFlow + || current instanceof ListView + || current instanceof TreeView + || current instanceof TableView + || current instanceof TreeTableView) { + return false; + } + current = current.getParent(); + } + return current == scrollPane; + } + + /// Returns whether a node has a visible window that can advance an animation. + /// + /// @param node the animation owner + /// @return `true` when a showing window is available + private static boolean canAnimate(Node node) { + @Nullable Scene scene = node.getScene(); + if (scene == null) { + return false; + } + @Nullable Window window = scene.getWindow(); + return window != null && window.isShowing(); + } + + /// Returns the current width used by a scroll pane's content. + /// + /// @param scrollPane the pane whose content is measured + /// @return the content width, or `0` for an empty pane + private static double contentWidth(ScrollPane scrollPane) { + @Nullable Node content = scrollPane.getContent(); + if (content == null) { + return 0.0; + } + + Bounds bounds = content.getBoundsInLocal(); + double width = bounds.getWidth(); + if (content instanceof Region region) { + double viewportHeight = scrollPane.getViewportBounds().getHeight(); + width = Math.max(width, region.prefWidth(viewportHeight > 0.0 ? viewportHeight : -1.0)); + } + return width; + } + + /// Returns the current height used by a scroll pane's content. + /// + /// @param scrollPane the pane whose content is measured + /// @return the content height, or `0` for an empty pane + private static double contentHeight(ScrollPane scrollPane) { + @Nullable Node content = scrollPane.getContent(); + if (content == null) { + return 0.0; + } + + Bounds bounds = content.getBoundsInLocal(); + double height = bounds.getHeight(); + if (content instanceof Region region) { + double viewportWidth = scrollPane.getViewportBounds().getWidth(); + height = Math.max(height, region.prefHeight(viewportWidth > 0.0 ? viewportWidth : -1.0)); + } + return height; + } + + /// Converts an event's horizontal amount to pixels. + /// + /// @param event the scroll event to convert + /// @return the signed horizontal distance in pixels + static double scrollDeltaX(ScrollEvent event) { + return switch (event.getTextDeltaXUnits()) { + case CHARACTERS -> event.getTextDeltaX() * DEFAULT_LINE_SCROLL_PIXELS; + case NONE -> event.getDeltaX(); + }; + } + + /// Converts an event's vertical amount to pixels. + /// + /// @param event the scroll event to convert + /// @param viewportHeight the viewport height used for page units + /// @return the signed vertical distance in pixels + static double scrollDeltaY(ScrollEvent event, double viewportHeight) { + return switch (event.getTextDeltaYUnits()) { + case LINES -> event.getTextDeltaY() * DEFAULT_LINE_SCROLL_PIXELS; + case PAGES -> event.getTextDeltaY() * viewportHeight; + case NONE -> event.getDeltaY(); + }; + } + + /// Computes a normalized target after applying a pixel delta. + /// + /// @param currentValue the current normalized target + /// @param scrollDelta the signed pixel delta + /// @param minValue the minimum normalized value + /// @param maxValue the maximum normalized value + /// @param scrollablePixels the pixel span represented by the normalized range + /// @return the updated, clamped normalized target + static double scrollTargetValue( + double currentValue, + double scrollDelta, + double minValue, + double maxValue, + double scrollablePixels + ) { + if (!canScroll(minValue, maxValue, scrollablePixels)) { + return currentValue; + } + + double currentPixels = pixelsForValue(currentValue, minValue, maxValue, scrollablePixels); + double targetPixels = clamp(currentPixels - scrollDelta, 0.0, scrollablePixels); + return valueForPixels(targetPixels, minValue, maxValue, scrollablePixels); + } + + /// Preserves an in-flight target's pixel offset after the content span changes. + /// + /// @param currentValue the current normalized target + /// @param previousScrollablePixels the previous scrollable pixel span + /// @param currentScrollablePixels the current scrollable pixel span + /// @param minValue the minimum normalized value + /// @param maxValue the maximum normalized value + /// @return the target mapped to the current pixel span + static double retargetScrollValue( + double currentValue, + double previousScrollablePixels, + double currentScrollablePixels, + double minValue, + double maxValue + ) { + if (previousScrollablePixels <= EPSILON + || currentScrollablePixels <= EPSILON + || close(minValue, maxValue)) { + return currentValue; + } + + double targetPixels = pixelsForValue(currentValue, minValue, maxValue, previousScrollablePixels); + return valueForPixels( + clamp(targetPixels, 0.0, currentScrollablePixels), + minValue, + maxValue, + currentScrollablePixels + ); + } + + /// Returns whether an axis can represent meaningful movement. + private static boolean canScroll(double minValue, double maxValue, double scrollablePixels) { + return scrollablePixels > EPSILON && !close(minValue, maxValue); + } + + /// Converts a normalized value to a pixel offset. + private static double pixelsForValue( + double value, + double minValue, + double maxValue, + double scrollablePixels + ) { + double clampedValue = clamp(value, minValue, maxValue); + return (clampedValue - minValue) / (maxValue - minValue) * scrollablePixels; + } + + /// Converts a pixel offset to a normalized value. + private static double valueForPixels( + double pixels, + double minValue, + double maxValue, + double scrollablePixels + ) { + return minValue + pixels / scrollablePixels * (maxValue - minValue); + } + + /// Clamps a value to an inclusive range. + private static double clamp(double value, double minValue, double maxValue) { + if (value <= minValue) { + return minValue; + } + return Math.min(value, maxValue); + } + + /// Returns whether two scroll values are effectively equal. + private static boolean close(double first, double second) { + return Math.abs(first - second) <= EPSILON; + } + + /// Interpolates linearly between two values. + private static double interpolate(double start, double end, double fraction) { + return start + (end - start) * fraction; } /// @author Glavo diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java new file mode 100644 index 00000000000..95530ea4a65 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java @@ -0,0 +1,65 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui; + +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Tests pixel-based smooth scroll target calculations. +@NotNullByDefault +public final class ScrollUtilsTest { + /// Verifies that a platform pixel delta is mapped through a custom normalized range. + @Test + public void mapsPixelDeltaToNormalizedScrollValue() { + assertEquals( + 17.0, + ScrollUtils.scrollTargetValue(15.0, -80.0, 10.0, 20.0, 400.0), + 0.000001 + ); + } + + /// Verifies that accumulated targets stop at both content boundaries. + @Test + public void clampsScrollTargetsToContentBoundaries() { + assertEquals(0.0, ScrollUtils.scrollTargetValue(0.1, 80.0, 0.0, 1.0, 400.0), 0.000001); + assertEquals(1.0, ScrollUtils.scrollTargetValue(0.9, -80.0, 0.0, 1.0, 400.0), 0.000001); + } + + /// Verifies that resizing content preserves the target's pixel offset rather than its normalized value. + @Test + public void retargetsScrollValueAfterContentResize() { + assertEquals( + 0.25, + ScrollUtils.retargetScrollValue(0.5, 400.0, 800.0, 0.0, 1.0), + 0.000001 + ); + assertEquals( + 1.0, + ScrollUtils.retargetScrollValue(0.75, 800.0, 300.0, 0.0, 1.0), + 0.000001 + ); + } + + /// Verifies that an axis without a usable pixel span remains unchanged. + @Test + public void leavesUnscrollableAxisUnchanged() { + assertEquals(0.4, ScrollUtils.scrollTargetValue(0.4, -80.0, 0.0, 1.0, 0.0), 0.000001); + } +} From 820b5441fe84b8bc86950de91a489482f0c9f5ad Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 1 Aug 2026 19:12:36 +0800 Subject: [PATCH 2/3] Improve smooth scrolling for scroll panes and list views Assisted-by: codex:gpt-5.6-sol --- .../org/jackhuang/hmcl/ui/ScrollUtils.java | 365 +++++++++++------- .../jackhuang/hmcl/ui/ScrollUtilsTest.java | 42 ++ 2 files changed, 275 insertions(+), 132 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java index e70af67cea2..be46315c633 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java @@ -20,10 +20,7 @@ package org.jackhuang.hmcl.ui; -import javafx.animation.Animation; import javafx.animation.Animation.Status; -import javafx.animation.KeyFrame; -import javafx.animation.Timeline; import javafx.animation.Transition; import javafx.event.EventHandler; import javafx.event.EventTarget; @@ -41,7 +38,6 @@ import javafx.scene.input.ScrollEvent; import javafx.scene.layout.Region; import javafx.stage.Window; -import javafx.util.Duration; import org.jackhuang.hmcl.ui.animation.Motion; import org.jetbrains.annotations.Nullable; @@ -49,81 +45,23 @@ * Utility class for ScrollPanes. */ final class ScrollUtils { - - public enum ScrollDirection { - UP(-1), RIGHT(-1), DOWN(1), LEFT(1); - - final int intDirection; - - ScrollDirection(int intDirection) { - this.intDirection = intDirection; - } - - public int intDirection() { - return intDirection; - } - } - private static final double DEFAULT_SPEED = 1.0; - private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0; - - private static final double CUTOFF_DELTA = 0.01; /// The property key for the smooth scroll state installed on a scroll pane. private static final Object SCROLL_PANE_STATE_KEY = new Object(); + /// The property key for the smooth scroll state installed on a virtual flow. + private static final Object VIRTUAL_FLOW_STATE_KEY = new Object(); + /// The pixel distance used for one platform-reported text line or character. private static final double DEFAULT_LINE_SCROLL_PIXELS = 40.0; + /// Restores the approximate distance of the former friction chain for pixel-unit input. + private static final double PIXEL_SCROLL_MULTIPLIER = 4.0; + /// The minimum meaningful difference between two scroll values. private static final double EPSILON = 0.000001; - /** - * Determines if the given ScrollEvent comes from a trackpad. - *

- * Although this method works in most cases, it is not very accurate. - * Since in JavaFX there's no way to tell if a ScrollEvent comes from a trackpad or a mouse - * we use this trick: I noticed that a mouse scroll has a delta of 32 (don't know if it changes depending on the device or OS) - * and trackpad scrolls have a way smaller delta. So depending on the scroll direction we check if the delta is lesser than 10 - * (trackpad event) or greater(mouse event). - * - * @see ScrollEvent#getDeltaX() - * @see ScrollEvent#getDeltaY() - */ - public static boolean isTrackPad(ScrollEvent event, ScrollDirection scrollDirection) { - return switch (scrollDirection) { - case UP, DOWN -> Math.abs(event.getDeltaY()) < 10; - case LEFT, RIGHT -> Math.abs(event.getDeltaX()) < 10; - }; - } - - /** - * Determines the scroll direction of the given ScrollEvent. - *

- * Although this method works fine, it is not very accurate. - * In JavaFX there's no concept of scroll direction, if you try to scroll with a trackpad - * you'll notice that you can scroll in both directions at the same time, both deltaX and deltaY won't be 0. - *

- * For this method to work we assume that this behavior is not possible. - *

- * If deltaY is 0 we return LEFT or RIGHT depending on deltaX (respectively if lesser or greater than 0). - *

- * Else we return DOWN or UP depending on deltaY (respectively if lesser or greater than 0). - * - * @see ScrollEvent#getDeltaX() - * @see ScrollEvent#getDeltaY() - */ - public static ScrollDirection determineScrollDirection(ScrollEvent event) { - double deltaX = event.getDeltaX(); - double deltaY = event.getDeltaY(); - - if (deltaY == 0.0) { - return deltaX < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT; - } else { - return deltaY < 0 ? ScrollDirection.DOWN : ScrollDirection.UP; - } - } - //================================================================================ // ScrollPanes //================================================================================ @@ -172,16 +110,31 @@ public static void addSmoothScrolling(VirtualFlow virtualFlow) { /// @author Glavo public static void addSmoothScrolling(VirtualFlow virtualFlow, double speed) { - addSmoothScrolling(virtualFlow, speed, DEFAULT_TRACK_PAD_ADJUSTMENT); + addSmoothScrolling(virtualFlow, speed, 1.0); } - /// @author Glavo + /// Adds smooth pixel scrolling to a vertical virtual flow. + /// + /// Repeated calls update the configuration without installing duplicate event filters. + /// + /// @param virtualFlow the virtual flow used by a list-like control + /// @param speed the multiplier applied to platform scroll distances + /// @param trackPadAdjustment the divisor applied to small pixel-unit deltas public static void addSmoothScrolling(VirtualFlow virtualFlow, double speed, double trackPadAdjustment) { - smoothScroll(virtualFlow, speed, trackPadAdjustment); - } + if (!virtualFlow.isVertical()) { + return; + } - private static 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}; - private static final Duration DURATION = Duration.millis(3); + @Nullable Object installedState = virtualFlow.getProperties().get(VIRTUAL_FLOW_STATE_KEY); + if (installedState instanceof VirtualFlowSmoothScrollState state) { + state.configure(speed, trackPadAdjustment); + } else { + virtualFlow.getProperties().put( + VIRTUAL_FLOW_STATE_KEY, + new VirtualFlowSmoothScrollState(virtualFlow, speed, trackPadAdjustment) + ); + } + } /// Maintains the accumulated scroll target and transition for one scroll pane. private static final class ScrollPaneSmoothScrollState { @@ -258,9 +211,8 @@ private void handleScroll(ScrollEvent event) { double viewportWidth = scrollPane.getViewportBounds().getWidth(); double viewportHeight = scrollPane.getViewportBounds().getHeight(); - double adjustment = scrollAdjustment(event); - double horizontalDelta = scrollDeltaX(event) * adjustment; - double verticalDelta = scrollDeltaY(event, viewportHeight) * adjustment; + double horizontalDelta = scrollDeltaX(event) * horizontalScrollScale(event); + double verticalDelta = scrollDeltaY(event, viewportHeight) * verticalScrollScale(event); double horizontalScrollablePixels = Math.max(0.0, contentWidth(scrollPane) - viewportWidth); double verticalScrollablePixels = Math.max(0.0, contentHeight(scrollPane) - viewportHeight); boolean canScrollHorizontally = canScroll( @@ -338,17 +290,24 @@ && close(horizontalDelta, 0.0) event.consume(); } - /// Returns the multiplier for this event's platform unit type. + /// Returns the multiplier for this event's horizontal unit type. /// /// @param event the event being handled - /// @return the configured speed, optionally divided for a small pixel delta - private double scrollAdjustment(ScrollEvent event) { - boolean usesPixelUnits = event.getTextDeltaXUnits() == ScrollEvent.HorizontalTextScrollUnits.NONE - && event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.NONE; - if (usesPixelUnits && isTrackPad(event, determineScrollDirection(event))) { - return speed / trackPadAdjustment; - } - return speed; + /// @return the configured scale for the horizontal delta + private double horizontalScrollScale(ScrollEvent event) { + return event.getTextDeltaXUnits() == ScrollEvent.HorizontalTextScrollUnits.NONE + ? pixelScrollScale(event, speed, trackPadAdjustment) + : speed; + } + + /// Returns the multiplier for this event's vertical unit type. + /// + /// @param event the event being handled + /// @return the configured scale for the vertical delta + private double verticalScrollScale(ScrollEvent event) { + return event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.NONE + ? pixelScrollScale(event, speed, trackPadAdjustment) + : speed; } /// Starts a transition or applies the target immediately when no window can render it. @@ -370,6 +329,110 @@ private void animateToTarget() { } } + /// Maintains accumulated pixel movement for one vertical virtual flow. + private static final class VirtualFlowSmoothScrollState { + /// The virtual flow receiving smooth scrolling. + private final VirtualFlow virtualFlow; + + /// The reusable pixel-distance transition. + private final VirtualFlowTransition animation; + + /// The event filter installed on the virtual flow. + private final EventHandler scrollHandler = this::handleScroll; + + /// Stops pending wheel motion before mouse-driven interaction. + private final EventHandler mouseHandler = event -> stopAnimation(); + + /// The configured scroll distance multiplier. + private double speed; + + /// The configured divisor for small pixel-unit deltas. + private double trackPadAdjustment; + + /// Creates and installs smooth scrolling for a virtual flow. + /// + /// @param virtualFlow the flow to configure + /// @param speed the scroll distance multiplier + /// @param trackPadAdjustment the small-delta divisor + private VirtualFlowSmoothScrollState( + VirtualFlow virtualFlow, + double speed, + double trackPadAdjustment + ) { + this.virtualFlow = virtualFlow; + this.animation = new VirtualFlowTransition(virtualFlow); + configure(speed, trackPadAdjustment); + virtualFlow.addEventFilter(ScrollEvent.SCROLL, scrollHandler); + virtualFlow.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); + } + + /// Updates scroll distance settings without installing another event filter. + /// + /// @param speed the scroll distance multiplier + /// @param trackPadAdjustment the small-delta divisor + private void configure(double speed, double trackPadAdjustment) { + this.speed = speed; + this.trackPadAdjustment = trackPadAdjustment; + } + + /// Stops pending smooth movement. + private void stopAnimation() { + animation.stop(); + } + + /// Handles one indirect vertical wheel or trackpad event. + /// + /// @param event the scroll event delivered to the flow + private void handleScroll(ScrollEvent event) { + if (event.isDirect() + || !virtualFlow.isVertical() + || !isEventTargetForVirtualFlow(virtualFlow, event.getTarget())) { + return; + } + + double viewportHeight = virtualFlow.getViewportLength(); + if (viewportHeight <= 0.0) { + viewportHeight = virtualFlow.getHeight(); + } + double scale = event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.NONE + ? pixelScrollScale(event, speed, trackPadAdjustment) + : speed; + double delta = scrollDeltaY(event, viewportHeight) * scale; + if (close(delta, 0.0)) { + return; + } + + double remainingDistance = animation.getStatus() == Status.STOPPED + ? 0.0 + : animation.remainingDistance(); + double targetDistance = remainingDistance - delta; + if (close(targetDistance, 0.0)) { + animation.stop(); + event.consume(); + return; + } + + double position = virtualFlow.getPosition(); + if ((position <= EPSILON && targetDistance < 0.0) + || (position >= 1.0 - EPSILON && targetDistance > 0.0)) { + animation.stop(); + return; + } + + if (!canAnimate(virtualFlow)) { + animation.stop(); + if (!close(virtualFlow.scrollPixels(targetDistance), 0.0)) { + event.consume(); + } + return; + } + + animation.configure(targetDistance); + animation.playFromStart(); + event.consume(); + } + } + /// Interpolates both scroll axes for one scroll pane. private static final class ScrollPaneTransition extends Transition { /// The scroll pane whose values are updated. @@ -392,7 +455,7 @@ private static final class ScrollPaneTransition extends Transition { /// @param scrollPane the pane whose values are updated private ScrollPaneTransition(ScrollPane scrollPane) { this.scrollPane = scrollPane; - setCycleDuration(Motion.LONG2); + setCycleDuration(Motion.MEDIUM2); setInterpolator(Motion.STANDARD_DECELERATE); } @@ -425,6 +488,57 @@ protected void interpolate(double fraction) { } } + /// Applies an eased pixel distance incrementally to one virtual flow. + private static final class VirtualFlowTransition extends Transition { + /// The virtual flow moved by this transition. + private final VirtualFlow virtualFlow; + + /// The total pixel distance requested for the current transition. + private double targetDistance; + + /// The interpolated distance already submitted to the virtual flow. + private double appliedDistance; + + /// Creates a reusable transition for a virtual flow. + /// + /// @param virtualFlow the flow moved by this transition + private VirtualFlowTransition(VirtualFlow virtualFlow) { + this.virtualFlow = virtualFlow; + setCycleDuration(Motion.MEDIUM2); + setInterpolator(Motion.STANDARD_DECELERATE); + } + + /// Reconfigures this transition for the remaining accumulated distance. + /// + /// @param targetDistance the signed pixel distance to move + private void configure(double targetDistance) { + stop(); + this.targetDistance = targetDistance; + this.appliedDistance = 0.0; + } + + /// Returns the distance that has not yet been submitted to the virtual flow. + /// + /// @return the signed remaining distance in pixels + private double remainingDistance() { + return targetDistance - appliedDistance; + } + + /// Applies the newly interpolated distance and stops when the flow reaches a boundary. + /// + /// @param fraction the eased animation fraction + @Override + protected void interpolate(double fraction) { + double nextDistance = targetDistance * fraction; + double requestedDistance = nextDistance - appliedDistance; + double actualDistance = virtualFlow.scrollPixels(requestedDistance); + appliedDistance = nextDistance; + if (!close(actualDistance, requestedDistance)) { + stop(); + } + } + } + /// Returns whether the event target belongs to this pane rather than to a nested scroll owner. /// /// @param scrollPane the pane that installed the event filter @@ -451,6 +565,38 @@ static boolean isEventTargetForScrollPane(ScrollPane scrollPane, EventTarget tar return current == scrollPane; } + /// Returns whether the event target belongs to this flow rather than to a nested scroll owner. + /// + /// @param virtualFlow the flow that installed the event filter + /// @param target the original event target + /// @return `true` when this flow owns the event + private static boolean isEventTargetForVirtualFlow(VirtualFlow virtualFlow, EventTarget target) { + if (!(target instanceof Node node)) { + return true; + } + + @Nullable Node current = node; + while (current != null && current != virtualFlow) { + if (current instanceof ScrollPane || current instanceof VirtualFlow) { + return false; + } + current = current.getParent(); + } + return current == virtualFlow; + } + + /// Returns the scale applied to a platform pixel-unit scroll delta. + /// + /// @param event the event being handled + /// @param speed the configured distance multiplier + /// @param trackPadAdjustment the configured small-delta divisor + /// @return the calibrated pixel scale + static double pixelScrollScale(ScrollEvent event, double speed, double trackPadAdjustment) { + double scale = speed * PIXEL_SCROLL_MULTIPLIER; + double dominantDelta = Math.max(Math.abs(event.getDeltaX()), Math.abs(event.getDeltaY())); + return dominantDelta > EPSILON && dominantDelta < 10.0 ? scale / trackPadAdjustment : scale; + } + /// Returns whether a node has a visible window that can advance an animation. /// /// @param node the animation owner @@ -624,51 +770,6 @@ private static double interpolate(double start, double end, double fraction) { return start + (end - start) * fraction; } - /// @author Glavo - private static void smoothScroll(VirtualFlow virtualFlow, double speed, double trackPadAdjustment) { - if (!virtualFlow.isVertical()) - return; - - final double[] derivatives = new double[FRICTIONS.length]; - - Timeline timeline = new Timeline(); - final EventHandler mouseHandler = event -> timeline.stop(); - final EventHandler scrollHandler = event -> { - if (event.getEventType() == ScrollEvent.SCROLL) { - ScrollDirection scrollDirection = determineScrollDirection(event); - if (scrollDirection == ScrollDirection.LEFT || scrollDirection == ScrollDirection.RIGHT) { - return; - } - double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed; - - derivatives[0] += scrollDirection.intDirection * currentSpeed; - if (timeline.getStatus() == Status.STOPPED) { - timeline.play(); - } - event.consume(); - } - }; - virtualFlow.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); - virtualFlow.addEventFilter(ScrollEvent.ANY, scrollHandler); - - timeline.getKeyFrames().add(new KeyFrame(DURATION, 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]; - virtualFlow.scrollPixels(dy); - - if (Math.abs(dy) < CUTOFF_DELTA) { - timeline.stop(); - } - })); - timeline.setCycleCount(Animation.INDEFINITE); - } - private ScrollUtils() { } } diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java index 95530ea4a65..ea1960c806d 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/ScrollUtilsTest.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.ui; +import javafx.scene.input.ScrollEvent; import org.jetbrains.annotations.NotNullByDefault; import org.junit.jupiter.api.Test; @@ -25,6 +26,17 @@ /// Tests pixel-based smooth scroll target calculations. @NotNullByDefault public final class ScrollUtilsTest { + /// Verifies that pixel-unit input retains approximately the former friction chain's total distance. + @Test + public void calibratesPixelUnitScrollDistance() { + ScrollEvent mouseWheelEvent = pixelScrollEvent(-32.0); + ScrollEvent trackPadEvent = pixelScrollEvent(-5.0); + + assertEquals(4.0, ScrollUtils.pixelScrollScale(mouseWheelEvent, 1.0, 1.0), 0.000001); + assertEquals(4.0, ScrollUtils.pixelScrollScale(trackPadEvent, 1.0, 1.0), 0.000001); + assertEquals(4.0 / 7.0, ScrollUtils.pixelScrollScale(trackPadEvent, 1.0, 7.0), 0.000001); + } + /// Verifies that a platform pixel delta is mapped through a custom normalized range. @Test public void mapsPixelDeltaToNormalizedScrollValue() { @@ -62,4 +74,34 @@ public void retargetsScrollValueAfterContentResize() { public void leavesUnscrollableAxisUnchanged() { assertEquals(0.4, ScrollUtils.scrollTargetValue(0.4, -80.0, 0.0, 1.0, 0.0), 0.000001); } + + /// Creates a vertical pixel-unit scroll event. + /// + /// @param deltaY the platform vertical delta + /// @return the scroll event + private static ScrollEvent pixelScrollEvent(double deltaY) { + return new ScrollEvent( + ScrollEvent.SCROLL, + 0.0, + 0.0, + 0.0, + 0.0, + false, + false, + false, + false, + false, + false, + 0.0, + deltaY, + 0.0, + deltaY, + ScrollEvent.HorizontalTextScrollUnits.NONE, + 0.0, + ScrollEvent.VerticalTextScrollUnits.NONE, + 0.0, + 0, + null + ); + } } From 9f8cd929ee9df21a508fe2f1d52d87ce1ae17e31 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 1 Aug 2026 19:30:16 +0800 Subject: [PATCH 3/3] Fix viewport height calculation in ScrollUtils for improved scrolling behavior --- HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java index be46315c633..30566b70470 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java @@ -390,10 +390,7 @@ private void handleScroll(ScrollEvent event) { return; } - double viewportHeight = virtualFlow.getViewportLength(); - if (viewportHeight <= 0.0) { - viewportHeight = virtualFlow.getHeight(); - } + double viewportHeight = virtualFlow.getHeight(); double scale = event.getTextDeltaYUnits() == ScrollEvent.VerticalTextScrollUnits.NONE ? pixelScrollScale(event, speed, trackPadAdjustment) : speed;