diff --git a/.gitignore b/.gitignore index c6d98d1..5ff6309 100644 --- a/.gitignore +++ b/.gitignore @@ -1,98 +1,38 @@ -# ---> JetBrains -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -# ---> Maven target/ -pom.xml.tag -pom.xml.releaseBackup -pom.xml.versionsBackup -pom.xml.next -release.properties -dependency-reduced-pom.xml -buildNumber.properties -.mvn/timing.properties -# https://github.com/takari/maven-wrapper#usage-without-binary-jar -.mvn/wrapper/maven-wrapper.jar +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ -# Eclipse m2e generated files -# Eclipse Core -.project -# JDT-specific (Eclipse Java Development Tools) +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated .classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..fdc35ea --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e0699d6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.imyeyu.fx + timi-fx + 0.0.1 + + + 21.0.2 + true + 21 + 21 + UTF-8 + + + + + org.openjfx + javafx-controls + ${fx.version} + + + com.imyeyu.config + timi-config + 0.0.1 + + + diff --git a/src/main/java/com/imyeyu/fx/BindingUtils.java b/src/main/java/com/imyeyu/fx/BindingUtils.java new file mode 100644 index 0000000..9fe9a24 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/BindingUtils.java @@ -0,0 +1,51 @@ +package com.imyeyu.fx; + +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.binding.StringBinding; +import javafx.beans.property.DoubleProperty; +import javafx.beans.value.ObservableValue; +import com.imyeyu.utils.Calc; +import javafx.scene.control.SplitPane; + +import java.util.Arrays; + +/** + * @author 夜雨 + * @version 2024-04-13 13:46 + */ +public final class BindingUtils { + + /** + * 多个布尔值绑定属性与运算 + * + * @param values 布尔值绑定 + * @return 与运算绑定 + */ + @SafeVarargs + public static BooleanBinding and(ObservableValue...values) { + return Bindings.createBooleanBinding(()-> Arrays.stream(values).allMatch(ObservableValue::getValue), values); + } + + /** + * 多个布尔值绑定属性或运算 + * + * @param values 布尔值绑定 + * @return 或运算绑定 + */ + @SafeVarargs + public static BooleanBinding or(ObservableValue...values) { + return Bindings.createBooleanBinding(()-> Arrays.stream(values).anyMatch(ObservableValue::getValue), values); + } + + + /** + * 双精度转整型字符串绑定属性 + * + * @param property 双精度浮点型属性 + * @return 字符串绑定属性 + */ + public static StringBinding integerStringBinding(DoubleProperty property) { + return Bindings.createStringBinding(() -> String.valueOf(Calc.round(property.get())), property); + } +} diff --git a/src/main/java/com/imyeyu/fx/ObservableUtils.java b/src/main/java/com/imyeyu/fx/ObservableUtils.java new file mode 100644 index 0000000..f6dad5a --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ObservableUtils.java @@ -0,0 +1,65 @@ +package com.imyeyu.fx; + +import javafx.beans.Observable; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.value.ObservableValue; +import javafx.collections.ObservableList; +import com.imyeyu.java.bean.CallbackArg; + +/** + * @author 夜雨 + * @version 2024-04-13 13:46 + */ +public final class ObservableUtils { + + /** + * 目标列表项有且仅有一项数据 + * + * @param list 列表 + * @return 监听对象 + */ + public static BooleanBinding onlyOnceInList(ObservableList list) { + return onlyOnceInList(list, list); + } + + /** + * 目标列表项有且仅有一项数据 + * + * @param list 列表 + * @param dependencies 触发器 + * @return 监听对象 + */ + public static BooleanBinding onlyOnceInList(ObservableList list, Observable... dependencies) { + return Bindings.createBooleanBinding(() -> list != null && list.size() == 1, dependencies); + } + + /** + * 目标列表项至少 2 个数据 + * + * @param list 列表 + * @return 监听对象 + */ + public static BooleanBinding multiInList(ObservableList list) { + return multiInList(list, list); + } + + /** + * 目标列表项至少 2 个数据 + * + * @param list 列表 + * @param dependencies 触发器 + * @return 监听对象 + */ + public static BooleanBinding multiInList(ObservableList list, Observable... dependencies) { + return Bindings.createBooleanBinding(() -> list != null && 2 <= list.size(), dependencies); + } + + public static void onChange(ObservableValue observableValue, CallbackArg callback) { + observableValue.addListener((obs, o, n) -> callback.handler(n)); + } + + public static void debug(ObservableValue observableValue) { + observableValue.addListener((obs, o, n) -> System.out.println(n)); + } +} diff --git a/src/main/java/com/imyeyu/fx/TimiFX.java b/src/main/java/com/imyeyu/fx/TimiFX.java new file mode 100644 index 0000000..ca73404 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/TimiFX.java @@ -0,0 +1,318 @@ +package com.imyeyu.fx; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.event.EventHandler; +import javafx.geometry.Rectangle2D; +import javafx.scene.Node; +import javafx.scene.control.Control; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.skin.VirtualFlow; +import javafx.scene.input.MouseEvent; +import javafx.scene.paint.Color; +import javafx.stage.PopupWindow; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.Window; +import javafx.stage.WindowEvent; +import com.imyeyu.fx.utils.ScreenFX; +import com.imyeyu.java.ref.Ref; +import com.imyeyu.utils.Calc; +import com.imyeyu.utils.OS; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * TimiFX - JavaFX 开发工具 + * + * @author 夜雨 + * @since 2021-02-14 10:51 + */ +public final class TimiFX { + + /** + * 阻止取消选择 + *
+	 *     addEventFilter(MouseEvent.MOUSE_PRESSED, EVENT_CONSUME_TGBTN);
+	 * 
+ */ + public static final EventHandler EVENT_CONSUME_TG_BTN = e -> { + if (e.getSource() instanceof ToggleButton btn && btn.isSelected()) { + e.consume(); + } + }; + + /** + * 十六进制颜色含透明度:0xFF00FF00 + * + * @param color 颜色 + * @return 十六进制颜色字符串 + */ + public static String toHexString(Color color) { + int r = Calc.round(color.getRed() * 255) << 24; + int g = Calc.round(color.getGreen() * 255) << 16; + int b = Calc.round(color.getBlue() * 255) << 8; + int a = Calc.round(color.getOpacity() * 255); + return String.format("0x%08X", (r + g + b + a)); + } + + /** + * 解析十六进制颜色字符串 + * + * @param hexColorString 十六进制颜色字符串 + * @return 颜色 + */ + public static Color fromHexString(String hexColorString) { + return Color.valueOf(hexColorString); + } + + /** + * 重新设置 SVG 大小 + * + * @param svg SVG 路径 + * @param scale 缩放倍率 + * @return SVG 路径 + */ + public static String resizeSVG(String svg, double scale) { + Pattern compile = Pattern.compile("\\d+\\.?\\d+"); + Matcher matcher = compile.matcher(svg); + return matcher.replaceAll(matchResult -> { + double d = Double.parseDouble(matchResult.group()) * scale; + if (d % 1 == 0) { + return String.valueOf((int) d); + } else { + return "%.2f".formatted(d); + } + }); + } + + /** + * 设置相对居中窗体 + * + * @param owner 依赖窗体 + * @param window 居中窗体 + */ + public static void relativeCenter(Window owner, Window window) { + double x = owner.getX() + owner.getWidth() * .5 - window.getWidth() * .5; + double y = owner.getY() + owner.getHeight() * .382 - window.getHeight() * .5; + + double idle = Math.abs(owner.getY() + 30 - y); + if (y < owner.getY()) { + // 防止跃出相对窗体上部分 + y += idle; + } + if (ScreenFX.outOfScreen(x, y)) { + // 坐标无效或窗体完全越出屏幕,设置到主屏幕 + relativeCenter4PrimaryScreen(window); + } + window.setX(x); + window.setY(y); + } + + /** + * 设置窗体到主屏幕中间(偏上少许) + * + * @param stage 窗体 + */ + public static void relativeCenter4PrimaryScreen(Window stage) { + relativeCenter4Screen(ScreenFX.primary, stage); + } + + /** + * 设置窗体到屏幕中间(偏上少许) + * + * @param screen 屏幕 + * @param stage 窗体 + */ + public static void relativeCenter4Screen(Screen screen, Window stage) { + if (screen == null) { + relativeCenter4PrimaryScreen(stage); + } else { + Rectangle2D r2d = screen.getBounds(); + double x = r2d.getMinX() + r2d.getWidth() * .5 - stage.getWidth() * .5; + double y = r2d.getMinY() + r2d.getHeight() * .382 - stage.getHeight() * .5; + + double idle = Math.abs(r2d.getMinY() + 30 - y); + if (y < r2d.getMinY()) { + // 防止跃出屏幕上部分 + y += idle; + } + if (Double.isNaN(x) || Double.isNaN(y)) { + stage.centerOnScreen(); + } else { + stage.setX(x); + stage.setY(y); + } + } + } + + /** + * 相对于某窗体居中显示新窗体(需预设宽高) + * + * @param owner 依赖窗体 + * @param window 需显示的窗体 + */ + public static void showCenter(Window owner, Window window) { + if (!window.isShowing()) { + relativeCenter(owner, window); + } + if (Double.isNaN(window.getX()) || Double.isNaN(window.getY())) { + final EventHandler onShown = window.getOnShown(); + window.setOnShown(e -> { + if (onShown != null) { + onShown.handle(e); + } + window.sizeToScene(); + relativeCenter(owner, window); + }); + } + if (window instanceof Stage stage) { + requestTop(stage); + } else if (window instanceof PopupWindow popupWindow) { + popupWindow.show(owner); + } + } + + /** + * 请求焦点,执行显示、置顶并聚焦 + * + * @param stage 窗体 + */ + public static void requestTop(Stage stage) { + stage.show(); + if (stage.isIconified()) { + stage.setIconified(false); + } + stage.setAlwaysOnTop(true); + stage.requestFocus(); + stage.setAlwaysOnTop(false); + } + + + /** + * 根据布尔值监听动态切换节点 CSS 类 + * + * @param node 节点 + * @param booleanProperty 布尔值监听 + * @param onTrue 为 true 时的 css 类 + * @param onFalse 为 false 时的 css 类 + */ + public static void toggleStyleClass(Node node, ReadOnlyBooleanProperty booleanProperty, String onTrue, String onFalse) { + if (booleanProperty.get()) { + node.getStyleClass().remove(onFalse); + node.getStyleClass().add(onTrue); + } + booleanProperty.addListener((obs, o, isTrue) -> { + if (isTrue) { + node.getStyleClass().remove(onFalse); + node.getStyleClass().add(onTrue); + } else { + node.getStyleClass().remove(onTrue); + node.getStyleClass().add(onFalse); + } + }); + } + + /** + * 根据布尔值监听动态切换节点 CSS 类 + * + * @param node 节点 + * @param booleanBinding 布尔值监听 + * @param onTrue 为 true 时的 css 类 + * @param onFalse 为 false 时的 css 类 + */ + public static void toggleStyleClass4Binding(Node node, BooleanBinding booleanBinding, String onTrue, String onFalse) { + if (booleanBinding.get()) { + node.getStyleClass().remove(onFalse); + node.getStyleClass().add(onTrue); + } + booleanBinding.addListener((obs, o, isTrue) -> { + if (isTrue) { + node.getStyleClass().remove(onFalse); + node.getStyleClass().add(onTrue); + } else { + node.getStyleClass().remove(onTrue); + node.getStyleClass().add(onFalse); + } + }); + } + + /** + * 指向半透 + * + * @param node 节点 + */ + public static void hoverOpacity(Node node) { + hoverOpacity(node, node); + } + + /** + * 指向半透 + * + * @param handler 触发节点 + * @param node 节点 + */ + public static void hoverOpacity(Node handler, Node node) { + node.opacityProperty().bind(Bindings.when(handler.hoverProperty()).then(.7).otherwise(1)); + } + + /** + * 指向即聚焦该节点 + * + * @param node 节点 + */ + public static void hoverFocus(Node node) { + node.hoverProperty().addListener((obs, o, isHover) -> { + if (isHover) { + node.requestFocus(); + } + }); + } + + /** + * 滚动指定项至控件可视范围的中央 + * + * @param control 控件({@link javafx.scene.control.ListView}, {@link javafx.scene.control.TableView} 等) + * @param index 指定项下标 + */ + public static void scrollToCenter(Control control, int index) { + try { + if (control.getSkin() == null) { + return; + } + // 偏移 + VirtualFlow flow = Ref.getFieldValue(control.getSkin(), "flow", VirtualFlow.class); + if (flow == null) { + throw new UnsupportedOperationException("unsupported this control"); + } + int offset = Calc.floor(flow.getHeight() * .5 / flow.getCell(0).getHeight()); + // 滚动 + Method method = Ref.getMethod(control.getClass(), "scrollTo", int.class); + method.setAccessible(true); + method.invoke(control, index - offset); + } catch (InvocationTargetException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + + + /** + * 重启程序,命令需自定 + * + * @param application FX 程序 + * @param command 重启命令 + * @throws Exception 异常 + */ + public static void doRestart(Application application, String command) throws Exception { + Platform.setImplicitExit(true); + OS.runAfterShutdown(command); + application.stop(); + } +} diff --git a/src/main/java/com/imyeyu/fx/bean/Interpolates.java b/src/main/java/com/imyeyu/fx/bean/Interpolates.java new file mode 100644 index 0000000..18a805f --- /dev/null +++ b/src/main/java/com/imyeyu/fx/bean/Interpolates.java @@ -0,0 +1,179 @@ +package com.imyeyu.fx.bean; + +import com.sun.scenario.animation.SplineInterpolator; + +/** + * 贝塞尔曲线,动画运动方式,常用 {@link #EASE_OUT_EXPO} + * + * @author 夜雨 + * @since 2022-02-16 22:43 + */ +public enum Interpolates { + + /** EASE ( .25, .1, .25, 1) */ EASE ( .25, .1, .25, 1), + /** LINEAR ( 0, 0, 1, 1) */ LINEAR ( 0, 0, 1, 1), + /** EASE_IN ( .42, 0, 1, 1) */ EASE_IN ( .42, 0, 1, 1), + /** EASE_OUT ( 0, 0, .58, 1) */ EASE_OUT ( 0, 0, .58, 1), + /** EASE_IN_OUT ( .42, 0, .58, 1) */ EASE_IN_OUT ( .42, 0, .58, 1), + /** EASE_IN_SINE ( .47, 0, .745, .715) */ EASE_IN_SINE ( .47, 0, .745, .715), + /** EASE_OUT_SINE ( .39, .575, .565, 1) */ EASE_OUT_SINE ( .39, .575, .565, 1), + /** EASE_IN_OUT_SINE (.445, .05, .55, .95) */ EASE_IN_OUT_SINE (.445, .05, .55, .95), + /** EASE_IN_QUAD ( .55, .085, .68, .53) */ EASE_IN_QUAD ( .55, .085, .68, .53), + /** EASE_OUT_QUAD ( .25, .46, .45, .94) */ EASE_OUT_QUAD ( .25, .46, .45, .94), + /** EASE_IN_OUT_QUAD (.455, .03, .515, .955) */ EASE_IN_OUT_QUAD (.455, .03, .515, .955), + /** EASE_IN_CUBIC ( .55, .055, .675, .19) */ EASE_IN_CUBIC ( .55, .055, .675, .19), + /** EASE_OUT_CUBIC (.215, .61, .355, 1) */ EASE_OUT_CUBIC (.215, .61, .355, 1), + /** EASE_IN_OUT_CUBIC (.645, .045, .355, 1) */ EASE_IN_OUT_CUBIC (.645, .045, .355, 1), + /** EASE_IN_QUART (.895, .03, .685, .22) */ EASE_IN_QUART (.895, .03, .685, .22), + /** EASE_OUT_QUART (.165, .84, .44, 1) */ EASE_OUT_QUART (.165, .84, .44, 1), + /** EASE_IN_OUT_QUART ( .77, 0, .175, 1) */ EASE_IN_OUT_QUART ( .77, 0, .175, 1), + /** EASE_IN_QUINT (.755, .05, .855, .06) */ EASE_IN_QUINT (.755, .05, .855, .06), + /** EASE_OUT_QUINT ( .23, 1, .32, 1) */ EASE_OUT_QUINT ( .23, 1, .32, 1), + /** EASE_IN_OUT_QUINT ( .86, 0, .07, 1) */ EASE_IN_OUT_QUINT ( .86, 0, .07, 1), + /** EASE_IN_EXPO ( .95, .05, .795, .035) */ EASE_IN_EXPO ( .95, .05, .795, .035), + /** EASE_OUT_EXPO ( .19, 1, .22, 1) */ EASE_OUT_EXPO ( .19, 1, .22, 1), + /** EASE_IN_OUT_EXPO ( 1, 0, 0, 1) */ EASE_IN_OUT_EXPO ( 1, 0, 0, 1), + /** EASE_IN_CIRC ( .6, .04, .98, .335) */ EASE_IN_CIRC ( .6, .04, .98, .335), + /** EASE_OUT_CIRC (.075, .82, .165, 1) */ EASE_OUT_CIRC (.075, .82, .165, 1), + /** EASE_IN_BACK ( .6, .28, .735, .045) */ EASE_IN_BACK ( .6, .28, .735, .045); + + final SplineInterpolator value; + + final double x1, y1, x2, y2; + + Interpolates(double px1, double py1, double px2, double py2) { + x1 = px1; + y1 = py1; + x2 = px2; + y2 = py2; + value = new SplineInterpolator(px1, py1, px2, py2); + } + + /** + * 构造二次贝塞尔点,值域为 [0, 1] + * + * @param precision 精度,构造数据量,不可小于 4 + * @return 贝塞尔点列表,第二维 [0]: x 轴坐标,[1]: y 轴坐标 + */ + public double[][] buildBezierPoint(double precision) { + return buildBezierPoint(1, precision); + } + + /** + * 构造二次贝塞尔点 + * + * @param boost 增幅,默认 1,值域为 [0, 1] + * @param precision 精度,构造数据量,不可小于 4 + * @return 贝塞尔点列表,第二维 [0]: x 轴坐标,[1]: y 轴坐标 + */ + public double[][] buildBezierPoint(double boost, double precision) { + if (precision < 4) { + throw new IllegalArgumentException("precision can not lessthan 4: " + precision); + } + precision = 1D / precision; + + Double[][] cps = new Double[4][2]; + cps[0][0] = cps[0][1] = 0D; + cps[1][0] = x1; + cps[1][1] = y1; + cps[2][0] = x2; + cps[2][1] = y2; + cps[3][0] = cps[3][1] = 1D; + + double[][] result = new double[(int) Math.ceil(1D / precision) + 1][2]; + + double[][] p; + int n = cps.length - 1; + int j, k, l = 0; + for (float i = 0; i <= 1; i += precision, l++) { + p = new double[n + 1][2]; + for (j = 0; j <= n; j++) { + p[j][0] = cps[j][0]; + p[j][1] = cps[j][1]; + } + for (k = 1; k <= n; k++) { + for (j = 0; j <= n - k; j++) { + p[j][0] = (1 - i) * p[j][0] + i * p[j + 1][0]; + p[j][1] = (1 - i) * p[j][1] + i * p[j + 1][1]; + } + } + p[0][0] *= boost; + p[0][1] *= boost; + + result[l][0] = p[0][0]; + result[l][1] = p[0][1]; + } + return result; + } + + /** + * 构造二次贝塞尔点值,值域为 [0, 1] + * + * @param precision 精度,构造数据量,不可小于 4 + * @return 贝塞尔点列表,值为 y 轴坐标 + */ + public double[] buildBezierPointValue(double precision) { + return buildBezierPointValue(1, precision); + } + + /** + * 构造二次贝塞尔点值 + * + * @param boost 增幅,默认 1,值域为 [0, 1] + * @param precision 精度,构造数据量,不可小于 4 + * @return 贝塞尔点列表,值为 y 轴坐标 + */ + public double[] buildBezierPointValue(double boost, double precision) { + double[][] points = buildBezierPoint(boost, precision); + double[] result = new double[points.length]; + for (int i = 0; i < points.length; i++) { + result[i] = points[i][1]; + } + return result; + } + + /** + * 获取控制点 1 的横坐标 + * + * @return 控制的 1 的横坐标 + */ + public double getX1() { + return x1; + } + + /** + * 获取控制点 1 的纵坐标 + * + * @return 控制的 1 的纵坐标 + */ + public double getY1() { + return y1; + } + + /** + * 获取控制点 2 的横坐标 + * + * @return 控制的 2 的横坐标 + */ + public double getX2() { + return x2; + } + + /** + * 获取控制点 2 的纵坐标 + * + * @return 控制的 2 的纵坐标 + */ + public double getY2() { + return y2; + } + + /** + * 获取插值器 + * + * @return 插值器 + */ + public SplineInterpolator getValue() { + return value; + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/config/BindingsConfig.java b/src/main/java/com/imyeyu/fx/config/BindingsConfig.java new file mode 100644 index 0000000..30569a8 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/config/BindingsConfig.java @@ -0,0 +1,317 @@ +package com.imyeyu.fx.config; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.FloatProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleFloatProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.ObservableSet; +import javafx.scene.layout.Background; +import javafx.scene.paint.Color; +import javafx.scene.paint.Paint; +import com.imyeyu.config.BaseConverter; +import com.imyeyu.config.ConfigLoader; +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.utils.BgFill; +import com.imyeyu.java.ref.Ref; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 配置绑定 + * + * @author 夜雨 + * @since 2021-10-26 10:27 + */ +public final class BindingsConfig { + + /** + * + * + * @author 夜雨 + * @version 2024-04-14 09:30 + */ + public abstract static class PropertyConverter, K> extends BaseConverter { + + @SuppressWarnings("unchecked") + @Override + protected K serialize(Field field, T t) { + if (t.getValue() == null) { + return null; + } + return (K) t.getValue(); + } + } + + /** + * + * + * @author 夜雨 + * @version 2024-04-28 15:23 + */ + public interface ObjectPropertyConverter { + + T serialize(Object t); + + Object deserialize(String data); + } + + public static final PropertyConverter STRING = new PropertyConverter<>() { + + @Override + protected StringProperty deserialize(Field field, Object data) { + SimpleStringProperty property = new SimpleStringProperty(); + if (data != null) { + property.set(data.toString()); + } + return property; + } + }; + + public static final PropertyConverter INTEGER = new PropertyConverter<>() { + + @Override + protected IntegerProperty deserialize(Field field, Object data) { + SimpleIntegerProperty property = new SimpleIntegerProperty(); + if (data != null) { + property.set(Integer.parseInt(data.toString())); + } + return property; + } + }; + + public static final PropertyConverter LONG = new PropertyConverter<>() { + + @Override + protected LongProperty deserialize(Field field, Object data) { + SimpleLongProperty property = new SimpleLongProperty(); + if (data != null) { + property.set(Long.parseLong(data.toString())); + } + return property; + } + }; + + public static final PropertyConverter DOUBLE = new PropertyConverter<>() { + + @Override + protected DoubleProperty deserialize(Field field, Object data) { + SimpleDoubleProperty property = new SimpleDoubleProperty(); + if (data != null) { + property.set(Double.parseDouble(data.toString())); + } + return property; + } + }; + + public static final PropertyConverter FLOAT = new PropertyConverter<>() { + + @Override + protected FloatProperty deserialize(Field field, Object data) { + SimpleFloatProperty property = new SimpleFloatProperty(); + if (data != null) { + property.set(Float.parseFloat(data.toString())); + } + return property; + } + }; + + public static final PropertyConverter BOOLEAN = new PropertyConverter<>() { + + @Override + protected BooleanProperty deserialize(Field field, Object data) { + SimpleBooleanProperty property = new SimpleBooleanProperty(); + if (data != null) { + property.set(Boolean.parseBoolean(data.toString())); + } + return property; + } + }; + + + /** 颜色转换,配置值格式为 0x11223344 */ + public static final BaseConverter, String> COLOR = new BaseConverter<>() { + + @Override + protected String serialize(Field field, ObjectProperty property) { + return TimiFX.toHexString(property.getValue()); + } + + @Override + protected ObjectProperty deserialize(Field field, String data) { + SimpleObjectProperty property = new SimpleObjectProperty<>(); + if (data != null) { + property.set(TimiFX.fromHexString(data)); + } + return property; + } + }; + + /** 颜色配置转换 */ + public static final BaseConverter, String> PAINT = new BaseConverter<>() { + + @Override + protected String serialize(Field field, ObjectProperty property) { + return TimiFX.toHexString(Color.valueOf(property.toString())); + } + + @Override + protected ObjectProperty deserialize(Field field, String data) { + SimpleObjectProperty property = new SimpleObjectProperty<>(); + if (data != null) { + property.set(Paint.valueOf(data)); + } + return property; + } + }; + + /** 基本填充背景颜色配置转换 */ + public static final BaseConverter, String> BG_FILL = new BaseConverter<>() { + + @Override + protected String serialize(Field field, ObjectProperty property) { + return TimiFX.toHexString(Color.valueOf(property.getValue().getFills().getFirst().getFill().toString())); + } + + @Override + protected ObjectProperty deserialize(Field field, String data) { + SimpleObjectProperty property = new SimpleObjectProperty<>(); + if (data != null) { + property.set(new BgFill(data).build()); + } + return property; + } + }; + + public static final BaseConverter, String> OBJECT = new BaseConverter<>() { + + @Override + protected String serialize(Field field, ObjectProperty property) { + Type genericType = field.getGenericType(); + if (genericType instanceof ParameterizedType pt) { + try { + Class propertyGenericClass = Class.forName(pt.getActualTypeArguments()[0].getTypeName()); + if (Enum.class.isAssignableFrom(propertyGenericClass)) { + if (property.get() == null) { + return null; + } + return property.get().toString(); + } + if (OBJECT_CONVERTER_MAP.containsKey(propertyGenericClass)) { + return OBJECT_CONVERTER_MAP.get(propertyGenericClass).serialize(property.get()).toString(); + } + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + throw new RuntimeException("not found " + field.getType() + " property converter"); + } + + @SuppressWarnings("unchecked") + @Override + protected ObjectProperty deserialize(Field field, String data) { + Type genericType = field.getGenericType(); + if (genericType instanceof ParameterizedType pt) { + try { + Class propertyGenericClass = Class.forName(pt.getActualTypeArguments()[0].getTypeName()); + if (Enum.class.isAssignableFrom(propertyGenericClass)) { + SimpleObjectProperty property = new SimpleObjectProperty<>(); + property.set(Ref.toType((Class>) propertyGenericClass, data)); + return property; + } + if (OBJECT_CONVERTER_MAP.containsKey(propertyGenericClass)) { + SimpleObjectProperty property = new SimpleObjectProperty<>(); + property.set(OBJECT_CONVERTER_MAP.get(propertyGenericClass).deserialize(data)); + return property; + } + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + throw new RuntimeException("not found " + genericType + " property converter"); + } + }; + + public static final BaseConverter, List> OBS_LIST = new BaseConverter<>() { + + @Override + protected List serialize(Field field, ObservableList obsList) { + return new ArrayList<>(obsList); + } + + @Override + protected ObservableList deserialize(Field field, List list) { + if (list == null) { + list = new ArrayList<>(); + } + return FXCollections.observableList(list); + } + }; + + public static final BaseConverter, Set> OBS_SET = new BaseConverter<>() { + + @Override + protected Set serialize(Field field, ObservableSet obsSet) { + return new HashSet<>(obsSet); + } + + @Override + protected ObservableSet deserialize(Field field, Set set) { + if (set == null) { + set = new HashSet<>(); + } + return FXCollections.observableSet(set); + } + }; + + public static final Map, PropertyConverter> DEFAULT_CONVERTER_MAP = Collections.unmodifiableMap(new HashMap<>() {{ + put(StringProperty.class, STRING); + put(IntegerProperty.class, INTEGER); + put(LongProperty.class, LONG); + put(DoubleProperty.class, DOUBLE); + put(FloatProperty.class, FLOAT); + put(BooleanProperty.class, BOOLEAN); + }}); + + public static final Map, ObjectPropertyConverter> OBJECT_CONVERTER_MAP = new HashMap<>(); + + public static ConfigLoader build(String path, Class clazz) { + return build(path, path, clazz); + } + + public static ConfigLoader build(String srcPath, String distPath, Class clazz) { + ConfigLoader loader = new ConfigLoader<>(srcPath, distPath, clazz); + addAllFXConverter(loader); + return loader; + } + + public static void addAllFXConverter(ConfigLoader loader) { + for (Map.Entry, BindingsConfig.PropertyConverter> item : BindingsConfig.DEFAULT_CONVERTER_MAP.entrySet()) { + loader.addConverter(item.getKey(), item.getValue()); + } + loader.addConverter(ObjectProperty.class, BindingsConfig.OBJECT); + loader.addConverter(ObservableSet.class, BindingsConfig.OBS_SET); + loader.addConverter(ObservableList.class, BindingsConfig.OBS_LIST); + } +} diff --git a/src/main/java/com/imyeyu/fx/draggable/Draggable.java b/src/main/java/com/imyeyu/fx/draggable/Draggable.java new file mode 100644 index 0000000..b6f46a2 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/draggable/Draggable.java @@ -0,0 +1,301 @@ +package com.imyeyu.fx.draggable; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.input.MouseButton; + +/** + * 抽象拖动属性 + * + * @author 夜雨 + * @since 2023-03-13 09:43 + */ +abstract class Draggable { + + /** 最小 X 坐标 */ + protected DoubleProperty minX; + + /** 最大 X 坐标 */ + protected DoubleProperty maxX; + + /** 最小 Y 坐标 */ + protected DoubleProperty minY; + + /** 最大 Y 坐标 */ + protected DoubleProperty maxY; + + /** 偏移补偿 X 值 */ + protected DoubleProperty offsetX; + + /** 偏移补偿 Y 值 */ + protected DoubleProperty offsetY; + + /** 是否启用 */ + protected BooleanProperty enable; + + /** 正否正在拖动 */ + protected BooleanProperty dragging; + + /** 触发按钮 */ + protected ObjectProperty eventButton; + + { + minX = new SimpleDoubleProperty(Double.NaN); + maxX = new SimpleDoubleProperty(Double.NaN); + minY = new SimpleDoubleProperty(Double.NaN); + maxY = new SimpleDoubleProperty(Double.NaN); + offsetX = new SimpleDoubleProperty(0); + offsetY = new SimpleDoubleProperty(0); + enable = new SimpleBooleanProperty(true); + dragging = new SimpleBooleanProperty(false); + eventButton = new SimpleObjectProperty<>(MouseButton.PRIMARY); + } + + /** + * 获取最小 X 值 + * + * @return 最小 X 值 + */ + public double getMinX() { + return minX.get(); + } + + /** + * 获取最小 X 值属性 + * + * @return 最小 X 值属性 + */ + public DoubleProperty minXProperty() { + return minX; + } + + /** + * 设置最小 X 值 + * + * @param minX 最小 X 值 + */ + public void setMinX(double minX) { + this.minX.set(minX); + } + + /** + * 获取最大 X 值 + * + * @return 最大 X 值 + */ + public double getMaxX() { + return maxX.get(); + } + + /** + * 获取最大 Y 值属性 + * + * @return 最大 Y 值属性 + */ + public DoubleProperty maxXProperty() { + return maxX; + } + + /** + * 设置最大 X 值 + * + * @param maxX 最大 X 值 + */ + public void setMaxX(double maxX) { + this.maxX.set(maxX); + } + + /** + * 获取最小 Y 值 + * + * @return 最小 Y 值 + */ + public double getMinY() { + return minY.get(); + } + + /** + * 获取最小 Y 值属性 + * + * @return 最小 Y 值属性 + */ + public DoubleProperty minYProperty() { + return minY; + } + + /** + * 设置最小 Y 值 + * + * @param minY 最小 Y 值 + */ + public void setMinY(double minY) { + this.minY.set(minY); + } + + /** + * 获取最大 Y 值 + * + * @return 最小 Y 值 + */ + public double getMaxY() { + return maxY.get(); + } + + /** + * 获取最小 Y 值属性 + * + * @return 最小 Y 值属性 + */ + public DoubleProperty maxYProperty() { + return maxY; + } + + /** + * 设置最大 Y 值 + * + * @param maxY 最大 Y 值 + */ + public void setMaxY(double maxY) { + this.maxY.set(maxY); + } + + /** + * 获取偏移 X 值 + * + * @return 偏移 X 值 + */ + public double getOffsetX() { + return offsetX.get(); + } + + /** + * 获取偏移 X 值属性 + * + * @return 偏移 X 值属性 + */ + public DoubleProperty offsetXProperty() { + return offsetX; + } + + /** + * 设置偏移 X 值 + * + * @param offsetX 偏移 X 值 + */ + public void setOffsetX(double offsetX) { + this.offsetX.set(offsetX); + } + + /** + * 获取偏移 Y 值 + * + * @return 偏移 Y 值 + */ + public double getOffsetY() { + return offsetY.get(); + } + + /** + * 获取偏移 Y 值属性 + * + * @return 偏移 Y 值属性 + */ + public DoubleProperty offsetYProperty() { + return offsetY; + } + + /** + * 设置偏移 Y 值 + * + * @param offsetY 偏移 Y 值 + */ + public void setOffsetY(double offsetY) { + this.offsetY.set(offsetY); + } + + /** + * 获取是否启用功能 + * + * @return true 为启用 + */ + public boolean isEnable() { + return enable.get(); + } + + /** + * 获取是否启用功能属性 + * + * @return 是否启用功能属性 + */ + public BooleanProperty enableProperty() { + return enable; + } + + /** + * 设置是否启用功能 + * + * @param enable true 为启用 + */ + public void setEnable(boolean enable) { + this.enable.set(enable); + } + + /** + * 获取是否正在拖动 + * + * @return true 为正在拖动 + */ + public boolean isDragging() { + return dragging.get(); + } + + /** + * 获取是否正在拖动属性 + * + * @return 拖动属性 + */ + public ReadOnlyBooleanProperty draggingProperty() { + return dragging; + } + + /** + * 设置是否正在拖动 + * + * @param dragging true 为正在拖动 + */ + protected void setDragging(boolean dragging) { + this.dragging.set(dragging); + } + + /** + * 获取触发拖动事件的按钮 + * + * @return 触发拖动事件的按钮 + */ + public MouseButton getEventButton() { + return eventButton.get(); + } + + /** + * 获取触发拖动事件的按钮属性 + * + * @return 触发拖动事件的按钮属性 + */ + public ObjectProperty eventButtonProperty() { + return eventButton; + } + + /** + * 设置触发拖动事件的按钮 + * + * @param eventButton 触发拖动事件的按钮 + */ + public void setEventButton(MouseButton eventButton) { + this.eventButton.set(eventButton); + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/draggable/DraggableNode.java b/src/main/java/com/imyeyu/fx/draggable/DraggableNode.java new file mode 100644 index 0000000..3573c39 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/draggable/DraggableNode.java @@ -0,0 +1,68 @@ +package com.imyeyu.fx.draggable; + +import javafx.scene.Node; +import javafx.scene.input.MouseEvent; + +/** + * 让组件可以自由拖动,此操作不影响布局,所有偏移限制基于变换坐标,也就是组件默认位置为原点 + * + * @author 夜雨 + * @since 2023-02-04 15:59 + */ +public class DraggableNode extends Draggable { + + private double oldTranslateX = -1, oldTranslateY = -1, oldScreenX = -1, oldScreenY = -1; + + /** + * 默认构造器 + * + * @param node 组件 + */ + public DraggableNode(Node node) { + this(node, node); + } + + /** + * 默认构造器 + * + * @param handler 触发组件 + * @param target 移动组件 + */ + public DraggableNode(Node handler, Node target) { + handler.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { + if (isEnable()) { + if (getEventButton() != null && e.getButton() != getEventButton()) { + return; + } + oldTranslateX = target.getTranslateX(); + oldTranslateY = target.getTranslateY(); + oldScreenX = e.getScreenX(); + oldScreenY = e.getScreenY(); + setDragging(true); + } + }); + handler.addEventFilter(MouseEvent.MOUSE_DRAGGED, e -> { + if (isDragging()) { + double newX = oldTranslateX + e.getScreenX() - oldScreenX; + double newY = oldTranslateY + e.getScreenY() - oldScreenY; + + if (!Double.isNaN(minX.get())) { + newX = Math.max(newX, minX.get()); + } + if (!Double.isNaN(maxX.get())) { + newX = Math.min(newX, maxX.get()); + } + if (!Double.isNaN(minY.get())) { + newY = Math.max(newY, minY.get()); + } + if (!Double.isNaN(maxY.get())) { + newY = Math.min(newY, maxY.get()); + } + + target.setTranslateX(newX + offsetX.get()); + target.setTranslateY(newY + offsetY.get()); + } + }); + handler.addEventFilter(MouseEvent.MOUSE_RELEASED, e -> setDragging(false)); + } +} diff --git a/src/main/java/com/imyeyu/fx/draggable/DraggableWindow.java b/src/main/java/com/imyeyu/fx/draggable/DraggableWindow.java new file mode 100644 index 0000000..16adade --- /dev/null +++ b/src/main/java/com/imyeyu/fx/draggable/DraggableWindow.java @@ -0,0 +1,42 @@ +package com.imyeyu.fx.draggable; + +import javafx.scene.Node; +import javafx.scene.input.MouseEvent; +import javafx.stage.Window; + +/** + * 让组件触发拖动窗体,通常在没有系统边框时使用 + * + * @author 夜雨 + * @since 2023-03-13 14:22 + */ +public class DraggableWindow extends Draggable { + + private double oldX, oldY; + + /** + * 标准构造 + * + * @param window 窗体 + * @param target 触发组件 + */ + public DraggableWindow(Window window, Node target) { + target.addEventFilter(MouseEvent.MOUSE_PRESSED, e -> { + if (isEnable()) { + if (getEventButton() != null && e.getButton() != getEventButton()) { + return; + } + oldX = e.getX(); + oldY = e.getY(); + setDragging(true); + } + }); + target.addEventFilter(MouseEvent.MOUSE_DRAGGED, e -> { + if (isDragging()) { + window.setX(e.getScreenX() - oldX + offsetX.get()); + window.setY(e.getScreenY() - oldY + offsetY.get()); + } + }); + target.addEventFilter(MouseEvent.MOUSE_RELEASED, e -> setDragging(false)); + } +} diff --git a/src/main/java/com/imyeyu/fx/task/PublicTask.java b/src/main/java/com/imyeyu/fx/task/PublicTask.java new file mode 100644 index 0000000..d77ed03 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/task/PublicTask.java @@ -0,0 +1,64 @@ +package com.imyeyu.fx.task; + +import javafx.concurrent.Task; + +/** + * Task 提升权限,所有操作视 UI 线程状态选择性调度(UI 繁忙时不更新),数据计算不应在更新回调中 + * + * @author 夜雨 + * @since 2022-01-08 16:24 + */ +abstract class PublicTask extends Task { + + /** + * 更新数据 + * + * @param value 数据对象 + */ + @Override + public void updateValue(T value) { + super.updateValue(value); + } + + /** + * 更新消息 + * + * @param message 消息 + */ + @Override + public void updateMessage(String message) { + super.updateMessage(message); + } + + /** + * 更新进度(自动计算百分比) + * + * @param workDone 已完成 + * @param max 最大 + */ + @Override + public void updateProgress(long workDone, long max) { + super.updateProgress(workDone, max); + } + + /** + * 更新进度(自动计算百分比) + * + * @param workDone 已完成 + * @param max 最大 + */ + @Override + public void updateProgress(double workDone, double max) { + super.updateProgress(workDone, max); + } + + /** + * 更新标题 + * + * @param title 标题 + */ + @Override + public void updateTitle(String title) { + super.updateTitle(title); + } +} diff --git a/src/main/java/com/imyeyu/fx/task/RunAsync.java b/src/main/java/com/imyeyu/fx/task/RunAsync.java new file mode 100644 index 0000000..0b153f1 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/task/RunAsync.java @@ -0,0 +1,301 @@ +package com.imyeyu.fx.task; + +import javafx.concurrent.Service; +import javafx.concurrent.Task; +import com.imyeyu.java.bean.Callback; +import com.imyeyu.java.bean.CallbackArg; +import com.imyeyu.java.bean.CallbackReturn; + +/** + * 异步线程执行,常用的构建异步线程任务 + * + *
+ *     // 快速构造
+ *     RunAsync.run(() -> {});           // 快速异步执行 {@link #run(Callback)}
+ *     RunAsync.run(() -> {}, () -> {}); // 快速异步回调 {@link #run(Callback, Callback)}
+ *     RunAsync.run(() -> 100, v -> {}); // 快速异步返回 {@link #callbackReturn(CallbackReturn, CallbackArg)}
+ *     RunAsync.later(() -> {}, 1000);   // 延时一秒执行 {@link #later(Callback, long)}
+ *
+ *     // 标准构造
+ *     new RunAsync<T>() {
+ *
+ *         @Override
+ *         protected T call() throws Exception {
+ *
+ *             // 这里不是 FX 线程,可以执行一些长时间的事情
+ *
+ *             update(new T()); // 主动更新数据
+ *             progress(.6);    // 更新进度
+ *             return new T();  // 返回更新数据
+ *         }
+ *
+ *         @Override
+ *         protected void onUpdate(T t) {
+ *             // 数据更新事件,属于 FX 线程,视 UI 线程状态回调
+ *         }
+ *
+ *         @Override
+ *         protected void onUpdateProgress(double progress) {
+ *             // 更新进度事件,属于 FX 线程,视 UI 线程状态回调
+ *         }
+ *
+ *         @Override
+ *         protected void onFinish() {
+ *             // 线程执行完成,属于 FX 线程
+ *         }
+ *
+ *         @Override
+ *         protected void onException(Throwable e) {
+ *             // 线程异常,属于 FX 线程
+ *         }
+ *     }.start();  <-- 千万别忘了启动任务
+ * 
+ * + * @author 夜雨 + * @since + * + * @param 数据处理返回类型 + * + * @author 夜雨 + * @version 2021-02-13 12:56 + */ +public abstract class RunAsync extends Service { + + /** 任务对象 */ + protected PublicTask task; + + /** 最近一次更新数据 */ + protected T lastT; + + protected boolean isInterrupted = false; + + @Override + protected Task createTask() { + task = new PublicTask<>() { + + protected T call() throws Exception { + return RunAsync.this.call(); + } + }; + task.setOnScheduled(e -> isInterrupted = false); + task.setOnSucceeded(e -> { + onFinish(); + onFinish(lastT); + onFinally(); + isInterrupted = false; + }); + task.setOnFailed(e -> { + onFinally(); + isInterrupted = false; + }); + task.valueProperty().addListener((obs, o, t) -> { + lastT = t; + onUpdate(t); + }); + task.messageProperty().addListener((obs, o, msg) -> onMessage(msg)); + task.progressProperty().addListener((obs, o, p) -> onProgress(p.doubleValue())); + task.exceptionProperty().addListener((obs, o, e) -> onException(e)); + return task; + } + + /** + * 执行事件(非 FX 线程) + * + * @return 处理结果 + * @throws Exception 处理异常 + */ + protected abstract T call() throws Exception; + + /** + * 主动更新数据,通常是非 FX 线程的 call 里触发,{@link #onUpdate(T)} 里视 UI 线程状态回调 + * + * @param t 更新值 + */ + protected void update(T t) { + lastT = t; + task.updateValue(t); + } + + /** + * 主动更新进度,非 FX 线程的 call 里触发,{@link #onProgress(double)} 里视 UI 线程状态回调 + * + * @param progress 进度值 + */ + protected void progress(double progress) { + task.updateProgress(progress, 1); + } + + /** + * 更新消息,非 FX 线程的 call 里触发,{@link #onMessage(String)} 里视 UI 线程状态回调 + * + * @param msg 消息 + */ + protected void message(String msg) { + task.updateMessage(msg); + } + + /** + * 数据更新事件,{@link #update(T)} 或 {@link #call()} 返回参数触发,属于 FX 线程 + * + * @param t 更新数据 + */ + protected void onUpdate(T t) { + // 子类实现 + } + + /** + * 更新进度事件,{@link #progress(double)} 触发,属于 FX 线程 + * + * @param progress 进度值 + */ + protected void onProgress(double progress) { + // 子类实现 + } + + /** + * 更新消息 + * + * @param msg 消息 + */ + protected void onMessage(String msg) { + // 子类实现 + } + + /** 完成事件(FX 线程,只要线程结束就会调用) */ + protected void onFinish() { + // 子类实现 + } + + /** + * 完成事件(FX 线程,只要线程结束就会调用,参数为 {@link #update(T)} 或 {@link #call()} 最后一次更新值) + * + * @param t 执行事件返回值 + */ + protected void onFinish(T t) { + // 子类实现 + } + + /** + * 发生异常(FX 线程) + * + * @param e 异常 + */ + protected void onException(Throwable e) { + e.printStackTrace(); + } + + /** 线程完成监听,无论正常完成还是出现异常,都会触发此方法 */ + protected void onFinally() { + // 子类实现 + } + + /** 中断任务 */ + public void interrupt() { + isInterrupted = true; + } + + // ---------- 静态构造 ---------- + + /** + * 快速构造稍后执行,触发事件属于 FX 线程 + * + * @param callback 异步事件 + * @param delay 延时时间(毫秒) + */ + public static void later(Callback callback, long delay) { + run(() -> { + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }, callback); + } + + /** + * 快速构造异步任务 + * + * @param callback 异步回调 + */ + public static void run(Callback callback) { + run(callback, null, null); + } + + /** + * 快速构造异步任务 + * + * @param asyncCallback 异步回调 + * @param finishCallback 异步完成回调 + */ + public static void run(Callback asyncCallback, Callback finishCallback) { + run(asyncCallback, finishCallback, null); + } + + /** + * 快速构造异步任务 + * + * @param asyncCallback 异步回调 + * @param exceptionCallback 异常回调 + */ + public static void run(Callback asyncCallback, CallbackArg exceptionCallback) { + run(asyncCallback, null, exceptionCallback); + } + + /** + * 快速构造异步任务(私有为了可读性,既然主要事件都用上了那应该使用 new {@link RunAsync}() 来保持可读性) + * + * @param asyncCallback 异步回调 + * @param finishCallback 异步完成回调 + * @param exceptionCallback 异常回调 + */ + private static void run(Callback asyncCallback, Callback finishCallback, CallbackArg exceptionCallback) { + new RunAsync() { + + @Override + protected Void call() { + asyncCallback.handler(); + return null; + } + + @Override + protected void onFinish() { + try { + if (finishCallback != null) { + finishCallback.handler(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + protected void onException(Throwable e) { + if (exceptionCallback != null) { + exceptionCallback.handler(e); + } + } + }.start(); + } + + /** + * 快速构造异步任务 + * + * @param asyncCallback 异步回调 + * @param finishCallback 异步完成回调 + * @param 回调数据类型 + */ + public static void callbackReturn(CallbackReturn asyncCallback, CallbackArg finishCallback) { + new RunAsync() { + + @Override + protected T call() { + return asyncCallback.handler(); + } + + @Override + protected void onFinish(T t) { + finishCallback.handler(t); + } + }.start(); + } +} diff --git a/src/main/java/com/imyeyu/fx/task/RunAsyncDaemon.java b/src/main/java/com/imyeyu/fx/task/RunAsyncDaemon.java new file mode 100644 index 0000000..a110cc2 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/task/RunAsyncDaemon.java @@ -0,0 +1,65 @@ +package com.imyeyu.fx.task; + +import javafx.concurrent.Task; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; + +/** + * 异步守护执行,如果 {@link RunAsync} 超过指定时间没有回调,将触发 onTimeout + * + * @author 夜雨 + * @since 2022-01-07 17:33 + */ +public abstract class RunAsyncDaemon extends RunAsync { + + private final int timeout; + + /** true 为回调成功 */ + protected boolean isCallBack; + + /** 默认构造器(执行超时 2 秒) */ + public RunAsyncDaemon() { + this(2000); + } + + /** + * 构造器 + * + * @param timeout 执行超时限时(毫秒) + */ + public RunAsyncDaemon(int timeout) { + this.timeout = timeout; + } + + @Override + protected Task createTask() { + super.createTask(); + + isCallBack = false; + task.setOnSucceeded(e -> { + onFinish(); + onFinish(lastT); + onFinally(); + isCallBack = true; + }); + task.setOnFailed(e -> { + onFinally(); + isCallBack = true; + }); + task.setOnRunning(e -> new Thread(() -> { + try { + Thread.sleep(timeout); + if (!isCallBack) { + onTimeout(); + } + } catch (InterruptedException ex) { + throw new TimiException(TimiCode.ERROR, "interrupted error", ex); + } + }).start()); + + return task; + } + + /** 超时事件 */ + protected abstract void onTimeout(); +} diff --git a/src/main/java/com/imyeyu/fx/task/RunAsyncScheduled.java b/src/main/java/com/imyeyu/fx/task/RunAsyncScheduled.java new file mode 100644 index 0000000..593d901 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/task/RunAsyncScheduled.java @@ -0,0 +1,352 @@ +package com.imyeyu.fx.task; + +import javafx.concurrent.ScheduledService; +import javafx.concurrent.Task; +import javafx.util.Duration; +import com.imyeyu.java.bean.Callback; +import com.imyeyu.java.bean.CallbackArg; +import com.imyeyu.java.bean.CallbackReturn; + +/** + * 异步任务,支持多次调度 + * + *
+ *     // ---------- 快速构造 ----------
+ *
+ *     // 每秒执行一次,参数 2 不是 FX 线程
+ *     // {@link RunAsyncScheduled#call(Duration, Callback)}
+ *     RunAsyncScheduled.call(Duration.seconds(1), () -> {});
+ *
+ *     // 每秒执行一次,参数 2 是 FX 线程
+ *     // {@link RunAsyncScheduled#finish(Duration, Callback)}
+ *     RunAsyncScheduled.finish(Duration.seconds(1), () -> {});
+ *
+ *     // 动态周期执行,参数 2 不是 FX 线程
+ *     // {@link RunAsyncScheduled#call(CallbackArg, Callback)}
+ *     RunAsyncScheduled.call(scheduled -> {}, () -> {});
+ *
+ *     // 动态周期执行,参数 2 是 FX 线程
+ *     // {@link RunAsyncScheduled#finish(CallbackArg, Callback)}
+ *     RunAsyncScheduled.finish(scheduled -> {}, () -> {});
+ *
+ *     // 每秒执行一次并返回,参数 3 (FX 线程)的入参是参数 2 (非 FX 线程)的返回
+ *     // {@link RunAsyncScheduled#call(Duration, CallbackReturn, CallbackArg)}
+ *     RunAsyncScheduled.call(Duration.seconds(1), () -> "result", result -> {});
+ *
+ *     // ---------- 标准构造 ----------
+ *     new RunAsyncScheduled<T>() {
+ *
+ *         @Override
+ *         protected void onScheduled() {
+ *             // 任务开始后执行,此时可调整任务的周期和延时
+ *         }
+ *
+ *         @Override
+ *         protected T call() {
+ *             // 这里不是 FX 线程,可以执行一些长时间的事情
+ *
+ *             update(new T()); // 主动更新数据
+ *             progress(.6);    // 更新进度
+ *             return new T();  // 返回更新数据
+ *         }
+ *
+ *         @Override
+ *         protected void onUpdate(T t) {
+ *             // 数据更新事件,属于 FX 线程,视 UI 线程状态回调
+ *         }
+ *
+ *         @Override
+ *         protected void onUpdateProgress(double progress) {
+ *             // 更新进度事件,属于 FX 线程,视 UI 线程状态回调
+ *         }
+ *
+ *         @Override
+ *         protected void onFinish() {
+ *             // 线程执行完成,属于 FX 线程
+ *         }
+ *
+ *         @Override
+ *         protected void onException(Throwable e) {
+ *             // 线程异常,属于 FX 线程
+ *         }
+ *     }.start();  <-- 千万别忘了启动任务
+ * 
+ * + * @author 夜雨 + * @since 2022-01-14 17:05 + */ +public abstract class RunAsyncScheduled extends ScheduledService { + + /** 执行任务 */ + protected PublicTask task; + + /** 最近一次更新数据 */ + protected T lastT; + + protected boolean isInterrupted = false; + + /** 默认构造器 */ + public RunAsyncScheduled() { + } + + /** + * 标准构造 + * + * @param period 周期 + */ + public RunAsyncScheduled(Duration period) { + setPeriod(period); + } + + @Override + protected Task createTask() { + task = new PublicTask<>() { + + protected T call() throws Exception { + return RunAsyncScheduled.this.call(); + } + }; + task.setOnScheduled(e -> { + onScheduled(); + isInterrupted = false; + }); + task.setOnRunning(e -> onRunning()); + task.setOnSucceeded(e -> { + onFinish(); + onFinish(lastT); + onFinally(); + isInterrupted = false; + }); + task.setOnFailed(e -> { + onFinally(); + isInterrupted = false; + }); + task.valueProperty().addListener((obs, o, t) -> { + lastT = t; + onUpdate(t); + }); + task.progressProperty().addListener((obs, o, p) -> onUpdateProgress(p.doubleValue())); + task.exceptionProperty().addListener((obs, o, e) -> onException(e)); + return task; + } + + /** + * 执行事件(非 FX 线程) + * + * @return 处理结果 + * @throws Exception 处理异常 + */ + protected T call() throws Exception { + // 子类实现 + return null; + } + + /** 每次开始运行时触发,在延时计算前触发 */ + protected void onScheduled() { + // 子类实现 + } + + /** {@link #call()} 运行时触发 */ + protected void onRunning() { + // 子类实现 + } + + /** + * 主动更新数据,通常是非 FX 线程的 call 里触发,{@link #onUpdate(T)} 里视 UI 线程状态回调 + * + * @param t 更新值 + */ + protected void update(T t) { + lastT = t; + task.updateValue(t); + } + + /** + * 主动更新进度,非 FX 线程的 call 里触发,{@link #onUpdateProgress(double)} 里视 UI 线程状态回调 + * + * @param progress 进度值 + */ + protected void progress(double progress) { + task.updateProgress(progress, 1); + } + + /** + * 数据更新事件,{@link #update(T)} 或 {@link #call()} 返回参数触发,属于 FX 线程 + * + * @param t 更新数据 + */ + protected void onUpdate(T t) { + // 子类实现 + } + + /** + * 更新进度事件,{@link #progress(double)} 触发,属于 FX 线程 + * + * @param progress 进度值 + */ + protected void onUpdateProgress(double progress) { + // 子类实现 + } + + /** 完成事件(FX 线程,只要线程结束就会调用) */ + protected void onFinish() { + // 子类实现 + } + + /** + * 完成事件(FX 线程,只要线程结束就会调用,参数为 {@link #update(T)} 或 {@link #call()} 最后一次更新值) + * + * @param t 执行事件返回值 + */ + protected void onFinish(T t) { + // 子类实现 + } + + /** 线程完成监听,无论正常完成还是出现异常,都会触发此方法 */ + protected void onFinally() { + // 子类实现 + } + + /** + * 发生异常(FX 线程) + * + * @param e 异常 + */ + protected void onException(Throwable e) { + e.printStackTrace(); + } + + /** 中断任务 */ + public void interrupt() { + isInterrupted = true; + } + + // ---------- 静态构造 ---------- + + /** + * 快速构造多次调度任务 + * + * @param period 周期 + * @param asyncCallback 异步线程执行 + * @return 任务 + */ + public static RunAsyncScheduled call(Duration period, Callback asyncCallback) { + RunAsyncScheduled scheduled = new RunAsyncScheduled<>() { + + @Override + protected Void call() { + asyncCallback.handler(); + return null; + } + }; + scheduled.setPeriod(period); + scheduled.start(); + return scheduled; + } + + /** + * 快速构造多次调度任务 + * + * @param period 周期 + * @param asyncCallback 异步线程回调 + * @return 任务 + */ + public static RunAsyncScheduled finish(Duration period, Callback asyncCallback) { + RunAsyncScheduled scheduled = new RunAsyncScheduled<>() { + + @Override + protected Void call() throws Exception { + return super.call(); + } + + @Override + protected void onFinish() { + asyncCallback.handler(); + } + }; + scheduled.setPeriod(period); + scheduled.start(); + return scheduled; + } + + /** + * 快速构造多次调度任务 + * + * @param period 周期 + * @param asyncCallback 异步线程执行 + * @param finishCallback 异步线程执行完成回调 + * @return 任务 + * @param 执行数据回调 + */ + public static RunAsyncScheduled call(Duration period, CallbackReturn asyncCallback, CallbackArg finishCallback) { + RunAsyncScheduled scheduled = new RunAsyncScheduled<>() { + + @Override + protected T call() { + return asyncCallback.handler(); + } + + @Override + protected void onFinish(T t) { + finishCallback.handler(t); + } + }; + scheduled.setPeriod(period); + scheduled.start(); + return scheduled; + } + + /** + * 快速构造动态周期调度任务 + * + * @param scheduleEvent 执行前调度,可重置执行周期 + * @param asyncCallback 异步线程执行 + * @return 任务 + */ + public static RunAsyncScheduled call(CallbackArg> scheduleEvent, Callback asyncCallback) { + RunAsyncScheduled scheduled = new RunAsyncScheduled<>() { + + @Override + protected void onScheduled() { + scheduleEvent.handler(this); + } + + @Override + protected Void call() { + asyncCallback.handler(); + return null; + } + }; + scheduled.start(); + return scheduled; + } + + /** + * 快速构造多次调度任务 + * + * @param scheduleEvent 执行前调度,可重置执行周期 + * @param finishCallback 异步线程执行完成回调 + * @return 任务 + */ + public static RunAsyncScheduled finish(CallbackArg> scheduleEvent, Callback finishCallback) { + RunAsyncScheduled scheduled = new RunAsyncScheduled<>() { + + @Override + protected void onScheduled() { + scheduleEvent.handler(this); + } + + @Override + protected Void call() { + return null; + } + + @Override + protected void onFinish() { + finishCallback.handler(); + } + }; + scheduled.start(); + return scheduled; + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/Anchor.java b/src/main/java/com/imyeyu/fx/utils/Anchor.java new file mode 100644 index 0000000..c0b7093 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/Anchor.java @@ -0,0 +1,164 @@ +package com.imyeyu.fx.utils; + +import javafx.scene.Node; +import javafx.scene.layout.AnchorPane; + +/** + * 锚点布局简化操作 + * + *
+ *     Anchor.def(node, top, right, bottom, left);
+ *     Anchor.def(node, topRightBottomLeft);
+ *     Anchor.def(node, topBottom, leftRight);
+ * 
+ * + * @author 夜雨 + * @since 2021-05-16 23:31 + */ +public class Anchor extends AnchorPane { + + /** + * {@link #leftTop(Node, Number, Number)} 的语义化方法 + * + * @param node 组件 + * @param x 距离左边 + * @param y 距离上边 + */ + public static void xy(Node node, Number x, Number y) { + def(node, y, null, null, x); + } + + /** + * 根据左上角定位 + * + * @param node 组件 + * @param left 距离左边 + * @param top 距离上边 + */ + public static void leftTop(Node node, Number left, Number top) { + def(node, top, null, null, left); + } + + /** + * 根据右上角定位 + * + * @param node 组件 + * @param right 距离右边 + * @param top 距离上边 + */ + public static void rightTop(Node node, Number right, Number top) { + def(node, top, right, null, null); + } + + /** + * 根据左下角定位 + * + * @param node 组件 + * @param left 距离左边 + * @param bottom 距离下边 + */ + public static void leftBottom(Node node, Number left, Number bottom) { + def(node, null, null, bottom, left); + } + + /** + * 根据右下角定位 + * + * @param node 组件 + * @param right 距离右边 + * @param bottom 距离下边 + */ + public static void rightBottom(Node node, Number right, Number bottom) { + def(node, null, right, bottom, null); + } + + /** + * 除了上边,其他贴紧父级组件 + * + * @param node 组件 + */ + public static void exTop(Node node) { + def(node, null, 0, 0, 0); + } + + /** + * 除了左边,其他贴紧父级组件 + * + * @param node 组件 + */ + public static void exLeft(Node node) { + def(node, 0, 0, 0, null); + } + + /** + * 除了右边,其他贴紧父级组件 + * + * @param node 组件 + */ + public static void exRight(Node node) { + def(node, 0, null, 0, 0); + } + + /** + * 除了下边,其他贴紧父级组件 + * + * @param node 组件 + */ + public static void exBottom(Node node) { + def(node, 0, 0, null, 0); + } + + /** + * 四边完全贴紧父级组件 + * + * @param node 组件 + */ + public static void def(Node node) { + def(node, 0, 0, 0, 0); + } + + /** + * 设置 AnchorPane 四边边距 + * + * @param node 组件 + * @param size 大小 + */ + public static void def(Node node, Number size) { + def(node, size, size, size, size); + } + + /** + * 设置 AnchorPane 上下和左右边距 + * + * @param node 组件 + * @param topBottom 上下 + * @param leftRight 左右 + */ + public static void def(Node node, Number topBottom, Number leftRight) { + def(node, topBottom, leftRight, topBottom, leftRight); + } + + /** + * 设置 AnchorPane 四边间距,传值 null 为不设定 + * + * @param node 组件 + * @param top 上 + * @param right 右 + * @param bottom 下 + * @param left 左 + */ + public static void def(Node node, Number top, Number right, Number bottom, Number left) { + if (top != null) { + setTopAnchor(node, top.doubleValue()); + } + if (left != null) { + setLeftAnchor(node, left.doubleValue()); + } + if (right != null) { + setRightAnchor(node, right.doubleValue()); + } + if (bottom != null) { + setBottomAnchor(node, bottom.doubleValue()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/utils/AnimationRenderer.java b/src/main/java/com/imyeyu/fx/utils/AnimationRenderer.java new file mode 100644 index 0000000..b355d65 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/AnimationRenderer.java @@ -0,0 +1,356 @@ +package com.imyeyu.fx.utils; + +import javafx.animation.AnimationTimer; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ReadOnlyDoubleProperty; +import javafx.beans.property.ReadOnlyIntegerProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.util.Duration; +import com.imyeyu.fx.task.RunAsyncScheduled; +import com.imyeyu.java.bean.Callback; +import com.imyeyu.java.bean.CallbackArg; + +import java.util.Iterator; +import java.util.LinkedList; + +/** + * 动画帧渲染器,可控帧率,在 JVM 启动参数含-Djavafx.animation.fullspeed=true时,预设 FPS + * 才可以突破屏幕刷新率。 + * + *
+ *     Box box = new Box(128, 128, 128);
+ *     box.setDrawMode(DrawMode.LINE);
+ *     box.setCullFace(CullFace.BACK);
+ *     box.setMaterial(new PhongMaterial(RED));
+ *     box.setRotationAxis(new Point3D(0, 64, 0));
+ *
+ *     // 以 240 FPS 每秒 90 度旋转一个 3D 立方体
+ *     AnimationRenderer renderer = new AnimationRenderer(240);
+ *     renderer.addRenderCallback(deltaSecond -> {
+ *         box.setRotate(box.getRotate() + 90 * deltaSecond);
+ *     });
+ * 
+ * + * @author 夜雨 + * @since 2023-03-08 09:48 + */ +public class AnimationRenderer { + + /** 渲染回调 */ + protected final LinkedList> renderCallbacks; + + /** 动画渲染队列 */ + protected final LinkedList animations; + + /** 渲染器 */ + protected final AnimationTimer timer; + + /** 平均帧生成时间 */ + protected final DoubleProperty mpf; + + /** 平均帧率 */ + protected final IntegerProperty fps; + + /** 状态计时器 */ + protected final RunAsyncScheduled statusTimer; + + /** 当前帧 */ + private double nowNanos; + + /** 上一帧 */ + private double lastNanos; + + /** 累计帧差(纳秒) */ + private double deltaNanos; + + /** 累计帧差(秒) */ + private double deltaSecond; + + /** 当前帧差 */ + private double betweenNanos; + + /** 标准帧生成时间(纳秒) */ + private double NPF; + + /** 预设渲染帧率 */ + private int prefFPS; + + /** 默认构造,60 FPS */ + public AnimationRenderer() { + this(60); + } + + /** + * 标准构造 + * + * @param prefFPS 预设帧率 + */ + public AnimationRenderer(int prefFPS) { + setPrefFPS(prefFPS); + + fps = new SimpleIntegerProperty(0); + mpf = new SimpleDoubleProperty(0); + renderCallbacks = new LinkedList<>(); + animations = new LinkedList<>(); + + timer = new AnimationTimer() { + + @Override + public void handle(long now) { + nowNanos = now; + if (0 < lastNanos) { + // 计算帧差 + deltaNanos += betweenNanos = nowNanos - lastNanos; + + // 累计帧差大于最小帧生成时间(足够渲染下一帧) + if (NPF <= deltaNanos) { + deltaSecond = deltaNanos * 1E-9; + + synchronized (renderCallbacks) { + for (CallbackArg renderCallback : renderCallbacks) { + renderCallback.handler(deltaSecond); + } + } + synchronized (animations) { + // 动画 + Iterator iterator = animations.iterator(); + while (iterator.hasNext()) { + Animation next = iterator.next(); + if (next.diedAt < millis()) { + next.callback.handler(deltaSecond, 1); + if (next.onFinishedEvent != null) { + next.onFinishedEvent.handler(); + } + iterator.remove(); + } else { + next.callback.handler(deltaSecond, (millis() - next.startAt) / next.ttl); + } + } + } + + // 消耗剩余帧差 + deltaNanos = deltaNanos % NPF; + } + } + lastNanos = nowNanos; + } + }; + + // 状态计算 + statusTimer = RunAsyncScheduled.finish(Duration.seconds(1), new Callback() { + + long total = 0, old; + + { + renderCallbacks.add(nowNanos -> total++); + } + + @Override + public void handler() { + // 帧生成时间 + mpf.set(betweenNanos * 1E-6); + + // 帧率 + fps.set((int) (total - old)); + old = total; + } + }); + } + + /** 启动 */ + public void start() { + nowNanos = lastNanos = 0; + deltaNanos = NPF; + timer.start(); + } + + /** 停止 */ + public void stop() { + timer.stop(); + } + + /** + * 添加渲染动画 + * + * @param duration 持续时间 + * @param callback 动画回调 + */ + public void render(Duration duration, AnimationCallback callback) { + render(duration, callback, null); + } + + /** + * 添加渲染动画 + * + * @param duration 持续时间 + * @param callback 动画回调 + * @param onFinishedEvent 动画完成回调 + */ + public void render(Duration duration, AnimationCallback callback, Callback onFinishedEvent) { + synchronized (animations) { + Animation animation = new Animation(); + animation.startAt = nowNanos * 1E-6; + animation.diedAt = animation.startAt + duration.toMillis(); + animation.ttl = duration.toMillis(); + animation.callback = callback; + animation.onFinishedEvent = onFinishedEvent; + animations.add(animation); + } + } + + /** + * 添加渲染回调 + * + * @param callback 回调 + */ + public void addRenderCallback(CallbackArg callback) { + synchronized (renderCallbacks) { + renderCallbacks.add(callback); + } + } + + /** + * 移除渲染回调 + * + * @param callback 回调 + */ + public void removeRenderCallback(CallbackArg callback) { + synchronized (renderCallbacks) { + renderCallbacks.remove(callback); + } + } + + /** + * 获取预设 FPS + * + * @return 预设 FPS + */ + public int getPrefFPS() { + return prefFPS; + } + + /** + * 预设 FPS,渲染器会尽量匹配此帧率渲染,可能会突破少许,系统资源紧张时实际渲染帧率会低于预设 + * + * @param prefFPS FPS 取值范围 [1, 1000] + */ + public void setPrefFPS(int prefFPS) { + if (prefFPS < 1) { + throw new IllegalArgumentException("pref fps can not less then 1"); + } + this.prefFPS = prefFPS; + this.NPF = 1E9 / prefFPS; + } + + /** + * 获取帧生成时间(毫秒) + * + * @return 帧生成时间 + */ + public double getMPF() { + return mpf.get(); + } + + /** + * 获取帧生成时间属性(毫秒) + * + * @return 帧生成时间属性(毫秒) + */ + public ReadOnlyDoubleProperty mpfProperty() { + return mpf; + } + + /** + * 获取当前渲染 FPS + * + * @return FPS + */ + public int getFPS() { + return fps.get(); + } + + /** + * 获取当前渲染 FPS 属性 + * + * @return FPS 属性 + */ + public ReadOnlyIntegerProperty fpsProperty() { + return fps; + } + + /** + * 获取累计帧差(纳秒) + * + * @return 累计帧差(纳秒) + */ + public double deltaNanos() { + return deltaNanos; + } + + /** + * 获取累计帧差(毫秒) + * + * @return 累计帧差(毫秒) + */ + public double deltaMillis() { + return deltaNanos * 1E-6; + } + + /** + * 获取累计帧差(秒) + * + * @return 累计帧差(秒) + */ + public double deltaSecond() { + return deltaNanos * 1E-9; + } + + /** @return 当前帧(纳秒) */ + public double nanos() { + return nowNanos; + } + + /** @return 当前帧(毫秒) */ + public double millis() { + return nowNanos * 1E-6; + } + + /** @return 当前帧(秒) */ + public double second() { + return nowNanos * 1E-9; + } + + /** + * 一次性动画 + * + * @author 夜雨 + * @since 2023-05-16 16:02 + */ + public static class Animation { + + double diedAt; + double startAt; + double ttl; + AnimationCallback callback; + Callback onFinishedEvent; + } + + /** + * 一次性动画回调 + * + * @author 夜雨 + * @since 2023-05-14 10:10 + */ + public interface AnimationCallback { + + /** + * 处理器 + * + * @param deltaSecond 帧差 + * @param percent 动画进度百分比,取值范围 [0, 1] + */ + void handler(double deltaSecond, double percent); + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/utils/BgFill.java b/src/main/java/com/imyeyu/fx/utils/BgFill.java new file mode 100644 index 0000000..c582537 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/BgFill.java @@ -0,0 +1,198 @@ +package com.imyeyu.fx.utils; + +import javafx.geometry.Insets; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.CornerRadii; +import javafx.scene.paint.Color; +import javafx.scene.paint.CycleMethod; +import javafx.scene.paint.LinearGradient; +import javafx.scene.paint.Paint; +import javafx.scene.paint.Stop; + +import java.util.Random; + +/** + * 快速构建简单背景填充,支持渐变背景。{@link #build()} 作为导出作用,最后执行 + * + *
+ *     new BgFill("red").build()                                 // 创建简单的背景填充
+ *     new BgFill("red", "blue").toRight().build()               // 创建由红到蓝,从左往右的渐变填充背景
+ *     new BgFill("red").radii(bgRadii).insets(bgInsets).build() // 设置圆角率和边距
+ * 
+ * + * @author 夜雨 + * @since 2021-02-13 12:52 + */ +public class BgFill { + + private Paint color; + private Stop[] stops; + private Paint linearGradient; + + /** 自定义圆角和内边距 */ + private Insets insets; + private CornerRadii radii; + + /** + * 纯色构造器 + * + * @param color 颜色 + */ + public BgFill(Paint color) { + this.color = color; + } + + /** + * 纯色构造器 + * + * @param color 颜色 + */ + public BgFill(String color) { + this.color = Paint.valueOf(color); + } + + /** + * 渐变构造器 + * + * @param start 起点颜色 + * @param end 结束颜色 + */ + public BgFill(String start, String end) { + stops = new Stop[]{new Stop(0, Color.valueOf(start)), new Stop(1, Color.valueOf(end))}; + } + + /** + * 设置四角圆角大小 + * + * @param size 大小 + * @return 本实例 + */ + public BgFill raddi(double size) { + this.radii = new CornerRadii(size); + return this; + } + + /** + * 设置四角圆角大小 + * + * @param topLeft 左上 + * @param topRight 右上 + * @param bottomRight 右下 + * @param bottomLeft 左下 + * @param asPercent true 为百分比数值 + * @return 本实例 + */ + public BgFill raddi(double topLeft, double topRight, double bottomRight, double bottomLeft, boolean asPercent) { + this.radii = new CornerRadii(topLeft, topRight, bottomRight, bottomLeft, asPercent); + return this; + } + + /** + * 设置内边距 + * + * @param insets 内边距 + * @return 本实例 + */ + public BgFill insets(Insets insets) { + this.insets = insets; + return this; + } + + /** + * 设置内边距 + * + * @param size 四边边距 + * @return 本实例 + */ + public BgFill insets(double size) { + return insets(size, size); + } + + /** + * 设置内边距 + * + * @param topBottom 上下边距 + * @param leftRight 左右边距 + * @return 本实例 + */ + public BgFill insets(double topBottom, double leftRight) { + this.insets = new Insets(topBottom, leftRight, topBottom, leftRight); + return this; + } + + /** + * 设置内边距 + * + * @param top 上边距 + * @param right 右边距 + * @param bottom 下边距 + * @param left 左边距 + * @return 本实例 + */ + public BgFill insets(double top, double right, double bottom, double left) { + this.insets = new Insets(top, right, bottom, left); + return this; + } + + /** + * 从左往右渐变 + * + * @return 本实例 + */ + public BgFill toRight() { + if (stops == null) { + throw new NullPointerException("please use BgFill(start, end) constructor "); + } + linearGradient = new LinearGradient(0, 0, 1, 0, true, CycleMethod.NO_CYCLE, stops); + return this; + } + + /** + * 从上往下渐变 + * + * @return 本实例 + */ + public BgFill toBottom() { + if (stops == null) { + throw new NullPointerException("please use BgFill(start, end) constructor "); + } + linearGradient = new LinearGradient(0, 0, 0, 1, true, CycleMethod.NO_CYCLE, stops); + return this; + } + + /** + * 导出背景 + * + * @return 背景 + */ + public Background build() { + return new Background(buildFill(linearGradient == null ? color : linearGradient)); + } + + /** + * 导出背景填充 + * + * @param color 颜色 + * @return 背景填充 + */ + public BackgroundFill buildFill(Paint color) { + radii = radii == null ? CornerRadii.EMPTY : radii; + insets = insets == null ? Insets.EMPTY : insets; + return new BackgroundFill(color, radii, insets); + } + + /** + * 快速构造测试背景 + * + * @return 背景 + */ + public static Background test() { + Random r = new Random(); + StringBuilder sb = new StringBuilder("#"); + for (int i = 0; i < 6; i++) { + sb.append(r.nextInt(8)); + } + return new BgFill(sb.toString()).build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/utils/BgImage.java b/src/main/java/com/imyeyu/fx/utils/BgImage.java new file mode 100644 index 0000000..6cb3ec7 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/BgImage.java @@ -0,0 +1,288 @@ +package com.imyeyu.fx.utils; + +import javafx.geometry.Side; +import javafx.scene.Node; +import javafx.scene.image.Image; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundImage; +import javafx.scene.layout.BackgroundPosition; +import javafx.scene.layout.BackgroundRepeat; +import javafx.scene.layout.BackgroundSize; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * 背景图片封装。{@link #build()} 作为导出作用,最后执行 + * + *

默认原图大小,不平铺,居原点 + * + *

+ *     // 适应缩放的 Cover 背景图片构建
+ *     new BgImage("/cover.png").cover().build()
+ * 
+ * + * @author 夜雨 + * @since 2021-02-13 12:53 + */ +public class BgImage { + + private BackgroundRepeat repeatX, repeatY; + + private Side sideH, sideV; + private double posH, posV, sizeW, sizeH; + private boolean posHAsPercent, posVAsPercent, sizeWAsPercent, sizeHAsPercent, isContain, isCover; + + private final Image img; + + /** + * 背景图构造器 + * + * @param url 图片位置(程序内资源) + */ + public BgImage(String url) { + this(new Image(url)); + } + + /** + * 背景图构造器 + * + * @param file 图片文件(程序外资源) + * @throws FileNotFoundException 找不到文件 + */ + public BgImage(File file) throws FileNotFoundException { + this(new Image(new FileInputStream(file))); + } + + /** + * 背景图构造器 + * + * @param img 图像 + */ + public BgImage(Image img) { + this.img = img; + // 初始化数据 + repeatX = repeatY = BackgroundRepeat.REPEAT; + sideH = Side.LEFT; + sideV = Side.TOP; + posH = posV = 0; + sizeW = sizeH = -1; + posHAsPercent = posVAsPercent = sizeWAsPercent = sizeHAsPercent = isContain = isCover = false; + } + + /** + * 双轴平铺方式 + * + * @param repeat 填充方式 + * @return 本实例 + */ + public BgImage repeat(BackgroundRepeat repeat) { + repeatX = repeatY = repeat; + return this; + } + + /** + * X 轴平铺方式 + * + * @param repeat 填充方式 + * @return 本实例 + */ + public BgImage repeatX(BackgroundRepeat repeat) { + repeatX = repeat; + return this; + } + + /** + * Y 轴平铺方式 + * + * @param repeat 填充方式 + * @return 本实例 + */ + public BgImage repeatY(BackgroundRepeat repeat) { + repeatY = repeat; + return this; + } + + /** + * 背景位置 + * + * @param sideH 相对水平位置,左或右 + * @param posH 相对水平距离 + * @param hAsPercent true 为水平距离是百分比 + * @param sideV 相对垂直位置,上或下 + * @param posV 相对垂直距离 + * @param vAsPercent true 为垂直距离是百分比 + * @return 本实例 + */ + public BgImage pos(Side sideH, double posH, boolean hAsPercent, Side sideV, double posV, boolean vAsPercent) { + this.sideH = sideH; + this.sideV = sideV; + this.posH = posH; + this.posV = posV; + this.posHAsPercent = hAsPercent; + this.posVAsPercent = vAsPercent; + return this; + } + + /** + * 水平对齐方式 + * + * @param side 相对位置 + * @param size 相对距离 + * @param asPercent true 百分比数据 + * @return 本实例 + */ + public BgImage horizontal(Side side, double size, boolean asPercent) { + sideH = side; + posH = size; + posHAsPercent = asPercent; + return this; + } + + /** + * 居左 + * + * @param size 距离 + * @return 本实例 + */ + public BgImage left(double size) { + return horizontal(Side.LEFT, size, false); + } + + /** + * 居右 + * + * @param size 距离 + * @return 本实例 + */ + public BgImage right(double size) { + return horizontal(Side.RIGHT, size, false); + } + + /** + * 垂直对齐方式 + * + * @param side 相对位置 + * @param size 相对距离 + * @param asPercent true 百分比数据 + * @return 本实例 + */ + public BgImage vertical(Side side, double size, boolean asPercent) { + sideV = side; + posV = size; + posVAsPercent = asPercent; + return this; + } + + /** + * 居上 + * + * @param size 距离 + * @return 本实例 + */ + public BgImage top(double size) { + return vertical(Side.TOP, size, false); + } + + + /** + * 居下 + * + * @param size 距离 + * @return 本实例 + */ + public BgImage bottom(double size) { + return vertical(Side.BOTTOM, size, false); + } + + /** + * 坐标轴定位 + * + * @param x 轴 + * @param y 轴 + * @return 本实例 + */ + public BgImage xy(double x, double y) { + sideH = Side.LEFT; + sideV = Side.TOP; + posH = x; + posV = y; + posHAsPercent = posVAsPercent = false; + return this; + } + + /** + * 图像大小 + * + * @param width 宽度 + * @param height 高度 + * @param widthAsPercent true 为参数是百分比 + * @param heightAsPercent true 为参数是百分比 + * @param isContain true 为尽量最大化图像 + * @param isCover true 为保持比例 + * @return 本实例 + */ + public BgImage size(double width, double height, boolean widthAsPercent, boolean heightAsPercent, boolean isContain, boolean isCover) { + sizeW = width; + sizeH = height; + sizeWAsPercent = widthAsPercent; + sizeHAsPercent = heightAsPercent; + this.isContain = isContain; + this.isCover = isCover; + return this; + } + + /** + * 自适应保持比例时尽量最大化的背景 + * + * @return 本实例 + */ + public BgImage cover() { + return size(100, 100, true, true, true, true); + } + + /** + * 居中背景图片 + * + * @return 本实例 + */ + public BgImage center() { + sideH = Side.LEFT; + sideV = Side.TOP; + posH = posV = 0; + posHAsPercent = posVAsPercent = true; + return this; + } + + /** @return 最终构造背景 */ + public Background build() { + return new Background(new BackgroundImage(img, repeatX, repeatY, new BackgroundPosition(sideH, posH, posHAsPercent, sideV, posV, posVAsPercent), new BackgroundSize(sizeW, sizeH, sizeWAsPercent, sizeHAsPercent, isContain, isCover))); + } + + /** + * JavaFX 设置组件背景,底色为默认 + * + * @param node 节点 + * @param url 背景路径 + * @param width 宽度 + * @param x X 轴偏移 + * @param y Y 轴偏移 + */ + public static void setBg(Node node, String url, int width, int x, int y) { + node.setStyle("-fx-background-size: " + width + ";" + "-fx-background-image: url('" + url + "');" + "-fx-background-insets: 0;" + "-fx-background-repeat: no-repeat;" + "-fx-background-position: " + x + " " + y); + } + + /** + * JavaFX 设置组件背景,底色为透明 + * + * @param node 节点 + * @param url 背景路径 + * @param width 宽度 + * @param x X 轴偏移 + * @param y Y 轴偏移 + */ + public static void setBgTp(Node node, String url, int width, int x, int y) { + node.setStyle("-fx-background-size: " + width + ";" + "-fx-background-image: url('" + url + "');" + "-fx-background-color: transparent;" + "-fx-background-insets: 0;" + "-fx-background-repeat: no-repeat;" + "-fx-background-position: " + x + " " + y); + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/BorderStroke.java b/src/main/java/com/imyeyu/fx/utils/BorderStroke.java new file mode 100644 index 0000000..46fc304 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/BorderStroke.java @@ -0,0 +1,467 @@ +package com.imyeyu.fx.utils; + +import javafx.geometry.Insets; +import javafx.scene.layout.Border; +import javafx.scene.layout.BorderStrokeStyle; +import javafx.scene.layout.BorderWidths; +import javafx.scene.layout.CornerRadii; +import javafx.scene.paint.Paint; + +import java.util.Random; + +/** + * 快速构建边框。{@link #build()} 作为导出作用,最后执行 + * + *
示例 + *
+ *     new BorderStroke("red").build();                  // 红色边框,默认样式(1 像素宽度直线,无圆角无内边距)
+ *     new BorderStroke("red").width(0, 1, 0, 1).build() // 红色直线左右边框
+ *     new BorderStroke("red").top().build()             // 红色直线上边框
+ *     new BorderStroke("red").radius(.5, true).build()  // 红色完全圆角边框
+ * 
+ * {@link #top()}, {@link #right()}, {@link #bottom()}, {@link #left()} 不可重复使用,因为这是针对性边框宽度设置,重复使用只有最后一个有效 + * + * @author 夜雨 + * @since 2021-02-14 10:42 + */ +public class BorderStroke { + + /** 空的边框 */ + public static final Border EMPTY = Border.EMPTY; + + /** 无边框样式 */ + public static final BorderStrokeStyle NONE = BorderStrokeStyle.NONE; + + /** 连续边框 */ + public static final BorderStrokeStyle SOLID = BorderStrokeStyle.SOLID; + + /** 断续边框 */ + public static final BorderStrokeStyle DASHED = BorderStrokeStyle.DASHED; + + /** 点状边框 */ + public static final BorderStrokeStyle DOTTED = BorderStrokeStyle.DOTTED; + + // 简写说明: TRBL 代表上右下左 + + /** 颜色 */ + private final Paint cT, cR, cB, cL; + + /** 宽度 */ + private double wT, wR, wB, wL; + + /** 圆角 */ + private double crTL, crTR, crBR, crBL; + + /** 内边距 */ + private double pT, pR, pB, pL; + + /** 圆角是否百分比 */ + private boolean crPercent = false; + + /** 样式 */ + private BorderStrokeStyle bssT, bssR, bssB, bssL; + + /** + * 边框构造 + * + * @param top 上边框颜色 + * @param right 右边框颜色 + * @param bottom 下边框颜色 + * @param left 左边框颜色 + */ + public BorderStroke(String top, String right, String bottom, String left) { + this(Paint.valueOf(top), Paint.valueOf(right), Paint.valueOf(bottom), Paint.valueOf(left)); + } + + /** + * 边框构造 + * + * @param color 边框颜色 + */ + public BorderStroke(String color) { + this(color, color, color, color); + } + + /** + * 边框构造 + * + * @param color 边框颜色 + */ + public BorderStroke(Paint color) { + this(color, color, color, color); + } + + /** + * 边框构造 + * + * @param top 上边框颜色 + * @param right 右边框颜色 + * @param bottom 下边框颜色 + * @param left 左边框颜色 + */ + public BorderStroke(Paint top, Paint right, Paint bottom, Paint left) { + cT = top; + cR = right; + cB = bottom; + cL = left; + // 默认参数 + wT = wR = wB = wL = 1; + crTL = crTR = crBR = crBL = 0; + pT = pR = pB = pL = 0; + bssT = bssR = bssB = bssL = SOLID; + } + + /** + * 1 像素上边框 + * + * @return 构造器 + */ + public BorderStroke top() { + return top(1); + } + + /** + * 1 像素右边框 + * + * @return 构造器 + */ + public BorderStroke right() { + return right(1); + } + + /** + * 1 像素下边框 + * + * @return 构造器 + */ + public BorderStroke bottom() { + return bottom(1); + } + + /** + * 1 像素左边框 + * + * @return 构造器 + */ + public BorderStroke left() { + return left(1); + } + + /** + * 上边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke top(double size) { + return width(size, 0, 0, 0); + } + + /** + * 右边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke right(double size) { + return width(0, size, 0, 0); + } + + /** + * 下边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke bottom(double size) { + return width(0, 0, size, 0); + } + + /** + * 左边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke left(double size) { + return width(0, 0, 0, size); + } + + /** + * 除了上边框,其他设置为 1 像素边框 + * + * @return 构造器 + */ + public BorderStroke exTop() { + return exTop(1); + } + + /** + * 除了右边框,其他设置为 1 像素边框 + * + * @return 构造器 + */ + public BorderStroke exRight() { + return exRight(1); + } + + /** + * 除了下边框,其他设置为 1 像素边框 + * + * @return 构造器 + */ + public BorderStroke exBottom() { + return exBottom(1); + } + + /** + * 除了左边框,其他设置为 1 像素边框 + * + * @return 构造器 + */ + public BorderStroke exLeft() { + return exLeft(1); + } + + /** + * 除了上边框,其他设置边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke exTop(double size) { + return width(0, size, size, size); + } + + /** + * 除了右边框,其他设置边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke exRight(double size) { + return width(size, 0, size, size); + } + + /** + * 除了下边框,其他设置边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke exBottom(double size) { + return width(size, size, 0, size); + } + + /** + * 除了左边框,其他设置边框 + * + * @param size 大小 + * @return 构造器 + */ + public BorderStroke exLeft(double size) { + return width(size, size, size, 0); + } + + /** + * 设置宽度 + * + * @param all 所有边框 + * @return 构造器 + */ + public BorderStroke width(double all) { + return width(all, all); + } + + /** + * 设置宽度 + * + * @param topBottom 上下 + * @param leftRight 左右 + * @return 构造器 + */ + public BorderStroke width(double topBottom, double leftRight) { + return width(topBottom, leftRight, topBottom, leftRight); + } + + /** + * 设置宽度 + * + * @param top 上 + * @param right 右 + * @param bottom 下 + * @param left 左 + * @return 构造器 + */ + public BorderStroke width(double top, double right, double bottom, double left) { + wT = top; + wR = right; + wB = bottom; + wL = left; + return this; + } + + /** + * 设置圆角 + * + * @param all 所有大小 + * @return 构造器 + */ + public BorderStroke radius(double all) { + return radius(all, false); + } + + /** + * 设置圆角 + * + * @param all 所有大小 + * @param asPercent 是否为百分比值(默认否) + * @return 构造器 + */ + public BorderStroke radius(double all, boolean asPercent) { + return radius(all, all, all, all, asPercent); + } + + /** + * 设置圆角 + * + * @param topLeft 左上角大小 + * @param topRight 右上角大小 + * @param bottomRight 右下角大小 + * @param bottomLeft 左下角大小 + * @param asPercent 是否为百分比值 + * @return 构造器 + */ + public BorderStroke radius(double topLeft, double topRight, double bottomRight, double bottomLeft, boolean asPercent) { + crTL = topLeft; + crTR = topRight; + crBR = bottomRight; + crBL = bottomLeft; + crPercent = asPercent; + return this; + } + + /** + * 虚线样式 + * + * @return 构造器 + */ + public BorderStroke dashed() { + return style(DASHED); + } + + /** + * 点阵样式 + * + * @return 构造器 + */ + public BorderStroke dotted() { + return style(DOTTED); + } + + /** + * 设置样式 + * + * @param all 所有边框样式 + * @return 构造器 + */ + public BorderStroke style(BorderStrokeStyle all) { + return style(all, all, all, all); + } + + /** + * 设置样式 + * + * @param top 上 + * @param right 右 + * @param bottom 下 + * @param left 左 + * @return 构造器 + */ + public BorderStroke style(BorderStrokeStyle top, BorderStrokeStyle right, BorderStrokeStyle bottom, BorderStrokeStyle left) { + bssT = top; + bssR = right; + bssB = bottom; + bssL = left; + return this; + } + + /** + * 设置内边距 + * + * @param all 所有边框内边距 + * @return 边框构造器 + */ + public BorderStroke padding(double all) { + return padding(all, all); + } + + /** + * 设置内边距 + * + * @param topBottom 上下 + * @param leftRight 左右 + * @return 边框构造器 + */ + public BorderStroke padding(double topBottom, double leftRight) { + return padding(topBottom, leftRight, topBottom, leftRight); + } + + /** + * 设置内边距 + * + * @param top 上 + * @param right 右 + * @param bottom 下 + * @param left 左 + * @return 边框构造器 + */ + public BorderStroke padding(double top, double right, double bottom, double left) { + pT = top; + pR = right; + pB = bottom; + pL = left; + return this; + } + + /** + * 构造边框 + * + * @return 边框 + */ + public Border build() { + return new Border( + new javafx.scene.layout.BorderStroke( + cT, cR, cB, cL, + bssT, bssR, bssB, bssL, + new CornerRadii(crTL, crTR, crBR, crBL, crPercent), + new BorderWidths(wT, wR, wB, wL), + new Insets(pT, pR, pB, pL) + ) + ); + } + + /** + * 快速构造测试边框 + * + * @param color 颜色 + * @return 边框 + */ + public static Border test(String color) { + return new BorderStroke(color).build(); + } + + /** + * 快速构造测试边框 + * + * @return 边框 + */ + public static Border test() { + Random r = new Random(); + StringBuilder sb = new StringBuilder("#"); + for (int i = 0; i < 6; i++) { + sb.append(r.nextInt(8)); + } + return test(sb.toString()); + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/Column.java b/src/main/java/com/imyeyu/fx/utils/Column.java new file mode 100644 index 0000000..add0e3a --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/Column.java @@ -0,0 +1,175 @@ +package com.imyeyu.fx.utils; + +import javafx.geometry.HPos; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.Priority; + +/** + * GridPane 列属性构造 + * + * @author 夜雨 + * @since 2021-12-17 23:45 + */ +public class Column extends ColumnConstraints { + + /** 通用键列 */ + public static final Column KEY = Column.build(); + + /** 通用值列 */ + public static final Column VALUE = Column.build().notFill().alwaysPriority(); + + /** 通用值列(填充) */ + public static final Column VALUE_FILL = Column.build().fill().alwaysPriority(); + + /** + * 默认构造器({@link #build(HPos)} 静态构造) + * + * @param align 水平对齐方式 + */ + private Column(HPos align) { + setHalignment(align); + } + + /** + * 设置最小宽度 + * + * @param minWidth 最小宽度 + * @return 本实例 + */ + public Column min(double minWidth) { + setMinWidth(minWidth); + return this; + } + + /** + * 设置最大宽度 + * + * @param maxWidth 最大宽度 + * @return 本实例 + */ + public Column max(double maxWidth) { + setMaxWidth(maxWidth); + return this; + } + + /** + * 设置宽度 + * + * @param width 宽度 + * @return 本实例 + */ + public Column width(double width) { + setPrefWidth(width); + return this; + } + + /** + * 以百分比设置宽度 + * + * @param percentWidth 宽度 + * @return 本实例 + */ + public Column percentWidth(double percentWidth) { + setPercentWidth(percentWidth); + return this; + } + + /** + * 保持子组件宽度填充 + * + * @return 本实例 + */ + public Column fill() { + setFillWidth(true); + return this; + } + + /** + * 子组件宽度不需填充 + * + * @return 本实例 + */ + public Column notFill() { + setFillWidth(false); + return this; + } + + /** + * 左对齐 + * + * @return 本实例 + */ + public Column left() { + setHalignment(HPos.LEFT); + return this; + } + + /** + * 居中对齐 + * + * @return 本实例 + */ + public Column center() { + setHalignment(HPos.CENTER); + return this; + } + + /** + * 右对齐 + * + * @return 本实例 + */ + public Column right() { + setHalignment(HPos.RIGHT); + return this; + } + + /** + * 列宽总是保持跟随容器尺寸变化 + * + * @return 本实例 + */ + public Column alwaysPriority() { + setHgrow(Priority.ALWAYS); + return this; + } + + /** + * 列宽总是不保持跟随容器尺寸变化 + * + * @return 本实例 + */ + public Column neverPriority() { + setHgrow(Priority.NEVER); + return this; + } + + /** + * 列宽不受其他列影响时可跟随容器尺寸变化 + * + * @return 本实例 + */ + public Column sometimesPriority() { + setHgrow(Priority.SOMETIMES); + return this; + } + + /** + * 静态构造列属性对象 + * + * @return 列属性对象 + */ + public static Column build() { + return new Column(HPos.LEFT); + } + + /** + * 静态构造列属性对象 + * + * @param align 水平对齐方式 + * @return 本实例 + */ + public static Column build(HPos align) { + return new Column(align); + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/DirectorySelector.java b/src/main/java/com/imyeyu/fx/utils/DirectorySelector.java new file mode 100644 index 0000000..25772fe --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/DirectorySelector.java @@ -0,0 +1,72 @@ +package com.imyeyu.fx.utils; + +import javafx.stage.DirectoryChooser; +import javafx.stage.Window; + +import java.io.File; +import java.util.Objects; + +/** + * 文件夹选择器 + * + * @author 夜雨 + * @version 2022-04-28 17:48 + */ +public class DirectorySelector { + + /** 选择器 */ + private final DirectoryChooser chooser; + + /** 默认构造器,路径为当前程序运行路径 */ + public DirectorySelector() { + this("./"); + } + + /** + * 构造器 + * + * @param path 默认路径 + */ + public DirectorySelector(String path) { + chooser = new DirectoryChooser(); + + File dir = new File(Objects.requireNonNullElse(path, "")); + if (dir.exists()) { + if (dir.isDirectory()) { + chooser.setInitialDirectory(dir); + } else { + chooser.setInitialDirectory(dir.getParentFile()); + } + } else { + chooser.setInitialDirectory(new File("./")); + } + } + + /** + * 设置标题 + * + * @param title 标题 + */ + public void setTitle(String title) { + chooser.setTitle(title); + } + + /** + * 显示文件夹选择 + * + * @param window 依赖窗体 + * @return 选择的文件夹 + */ + public File show(Window window) { + return chooser.showDialog(window); + } + + /** + * 获取选择器 + * + * @return 选择器 + */ + public DirectoryChooser getChooser() { + return chooser; + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/fx/utils/FileSelector.java b/src/main/java/com/imyeyu/fx/utils/FileSelector.java new file mode 100644 index 0000000..9d348e0 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/FileSelector.java @@ -0,0 +1,103 @@ +package com.imyeyu.fx.utils; + +import javafx.stage.FileChooser; +import javafx.stage.Window; + +import java.io.File; +import java.util.List; +import java.util.Objects; + +/** + * 文件选择器 + * + * @author 夜雨 + * @version 2022-04-14 19:38 + */ +public class FileSelector { + + /** 选择器 */ + private final FileChooser chooser; + + /** 默认构造器 */ + public FileSelector() { + this("./"); + } + + /** + * 构造文件选择器 + * + * @param path 默认路径 + */ + public FileSelector(String path) { + chooser = new FileChooser(); + + File dir = new File(Objects.requireNonNullElse(path, "")); + if (dir.exists()) { + if (dir.isFile()) { + chooser.setInitialDirectory(dir.getParentFile()); + } else { + chooser.setInitialDirectory(dir); + } + } else { + chooser.setInitialDirectory(new File("./")); + } + } + + /** + * 设置标题 + * + * @param title 标题 + */ + public void setTitle(String title) { + chooser.setTitle(title); + } + + /** + * 添加格式限制 + * + * @param description 说明 + * @param format 格式 + */ + public void addFilter(String description, String... format) { + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(description, format)); + } + + /** + * 单选文件 + * + * @param window 依赖窗体 + * @return 选择的文件 + */ + public File single(Window window) { + return chooser.showOpenDialog(window); + } + + /** + * 多选文件 + * + * @param window 依赖窗体 + * @return 选择的文件 + */ + public List multi(Window window) { + return chooser.showOpenMultipleDialog(window); + } + + /** + * 保存文件 + * + * @param window 依赖窗体 + * @return 选择的文件夹 + */ + public File save(Window window) { + return chooser.showSaveDialog(window); + } + + /** + * 获取选择器 + * + * @return 选择器 + */ + public FileChooser getChooser() { + return chooser; + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/NoSelectionModel.java b/src/main/java/com/imyeyu/fx/utils/NoSelectionModel.java new file mode 100644 index 0000000..f90d609 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/NoSelectionModel.java @@ -0,0 +1,53 @@ +package com.imyeyu.fx.utils; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.scene.control.MultipleSelectionModel; + +/** + * 空的选择器,适用于 ListView + * + * @param 数据类型 + * + * 夜雨 创建于 2021-05-16 23:29 + */ +public class NoSelectionModel extends MultipleSelectionModel { + + public ObservableList getSelectedIndices() { + return FXCollections.emptyObservableList(); + } + + public ObservableList getSelectedItems() { + return FXCollections.emptyObservableList(); + } + + public void selectIndices(int index, int... indices) {} + + public void selectAll() {} + + public void selectFirst() {} + + public void selectLast() {} + + public void clearAndSelect(int index) {} + + public void select(int index) {} + + public void select(T obj) {} + + public void clearSelection(int index) {} + + public void clearSelection() {} + + public boolean isSelected(int index) { + return false; + } + + public boolean isEmpty() { + return true; + } + + public void selectPrevious() {} + + public void selectNext() {} +} diff --git a/src/main/java/com/imyeyu/fx/utils/Row.java b/src/main/java/com/imyeyu/fx/utils/Row.java new file mode 100644 index 0000000..992b249 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/Row.java @@ -0,0 +1,165 @@ +package com.imyeyu.fx.utils; + +import javafx.geometry.VPos; +import javafx.scene.layout.Priority; +import javafx.scene.layout.RowConstraints; + +/** + * GridPane 行属性构造 + * + * @author 夜雨 + * @since 2021-12-17 23:45 + */ +public class Row extends RowConstraints { + + /** + * 默认构造器({@link #build(VPos)} 静态构造) + * + * @param align 垂直对齐方式 + */ + private Row(VPos align) { + setValignment(align); + } + + /** + * 设置最小高度 + * + * @param minHeight 最小高度 + * @return 本实例 + */ + public Row min(double minHeight) { + setMinHeight(minHeight); + return this; + } + + /** + * 设置最大高度 + * + * @param maxHeight 最大高度 + * @return 本实例 + */ + public Row max(double maxHeight) { + setMaxHeight(maxHeight); + return this; + } + + /** + * 设置高度 + * + * @param height 高度 + * @return 本实例 + */ + public Row height(double height) { + setPrefHeight(height); + return this; + } + + /** + * 以百分比设置高度 + * + * @param percentHeight 高度 + * @return 本实例 + */ + public Row percentHeight(double percentHeight) { + setPercentHeight(percentHeight); + return this; + } + + /** + * 保持子组件高度填充 + * + * @return 本实例 + */ + public Row fill() { + setFillHeight(true); + return this; + } + + /** + * 子组件高度不需填充 + * + * @return 本实例 + */ + public Row notFill() { + setFillHeight(false); + return this; + } + + /** + * 顶部对齐 + * + * @return 本实例 + */ + public Row top() { + setValignment(VPos.TOP); + return this; + } + + /** + * 居中对齐 + * + * @return 本实例 + */ + public Row center() { + setValignment(VPos.CENTER); + return this; + } + /** + * 底部对齐 + * + * @return 本实例 + */ + public Row bottom() { + setValignment(VPos.BOTTOM); + return this; + } + + /** + * 行高总是保持跟随容器尺寸变化 + * + * @return 本实例 + */ + public Row alwaysPriority() { + setVgrow(Priority.ALWAYS); + return this; + } + + /** + * 行高总是不保持跟随容器尺寸变化 + * + * @return 本实例 + */ + public Row neverPriority() { + setVgrow(Priority.NEVER); + return this; + } + + /** + * 行高不受其他行影响时可跟随容器尺寸变化 + * + * @return 本实例 + */ + public Row sometimesPriority() { + setVgrow(Priority.SOMETIMES); + return this; + } + + /** + * 静态构造行属性对象 + * + * @return 行属性对象 + */ + public static Row build() { + return new Row(VPos.CENTER); + } + + /** + * 静态构造行属性对象 + * + * @param align 垂直对齐方式 + * @return 本实例 + */ + public static Row build(VPos align) { + return new Row(align); + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/ScreenFX.java b/src/main/java/com/imyeyu/fx/utils/ScreenFX.java new file mode 100644 index 0000000..51c78ec --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/ScreenFX.java @@ -0,0 +1,122 @@ +package com.imyeyu.fx.utils; + +import javafx.collections.ListChangeListener; +import javafx.geometry.BoundingBox; +import javafx.geometry.Bounds; +import javafx.geometry.Rectangle2D; +import javafx.stage.Screen; +import javafx.stage.Stage; +import com.imyeyu.utils.Digest; + +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; + +/** + * 多屏操作,可在所有屏幕显示标识,并提供选择参考 + * + * @author 夜雨 + * @since 2021-11-11 21:02 + */ +public class ScreenFX extends Stage { + + /** 主屏幕 */ + public static final Screen primary = Screen.getPrimary(); + + /** 所有屏幕 */ + public static final List SCREENS; + + static { + SCREENS = new ArrayList<>(Screen.getScreens()); + SCREENS.sort((s1, s2) -> (int) (s2.getBounds().getMinX() - s1.getBounds().getMinX())); + Screen.getScreens().addListener((ListChangeListener) c -> { + SCREENS.clear(); + SCREENS.addAll(Screen.getScreens()); + SCREENS.sort((s1, s2) -> (int) (s2.getBounds().getMinX() - s1.getBounds().getMinX())); + }); + } + + // ---------- 静态功能 ---------- + + /** + * 此屏幕参数 MD5 + * + * @param screen 屏幕 + * @return MD5 + */ + public static String md5(Screen screen) throws NoSuchAlgorithmException { + Rectangle2D r = screen.getBounds(); + return Digest.md5(String.valueOf(r.getMinX()) + r.getMinY() + r.getMaxX() + r.getMaxY() + r.getWidth() + r.getHeight()); + } + + /** + * 获取坐标所在屏幕 + * + * @param x 坐标 + * @param y 坐标 + * @return 屏幕(在所有屏幕之外时返回 null) + */ + public static Screen getScreenByXY(double x, double y) { + for (int i = 0; i < SCREENS.size(); i++) { + if (isInScreen(SCREENS.get(i), x, y)) { + return SCREENS.get(i); + } + } + return null; + } + + /** + * 该坐标是否溢出屏幕 + * + * @param x 坐标 + * @param y 坐标 + * @return true 为溢出 + */ + public static boolean outOfScreen(double x, double y) { + return getScreenByXY(x, y) == null; + } + + /** + * 判定该坐标是否在屏幕内 + * + * @param screen 屏幕 + * @param x 坐标 + * @param y 坐标 + * @return true 为存在 + */ + public static boolean isInScreen(Screen screen, double x, double y) { + final Rectangle2D r = screen.getBounds(); + return !(x < r.getMinX() || r.getMaxX() < x || y < r.getMinY() || r.getMaxY() < y); + } + + /** + * 获取任务栏属性 + * + * @param screen 屏幕 + * @return 任务栏属性 + */ + public static Bounds getTaskbarBounds(Screen screen) { + if (screen == null) { + return null; + } + Rectangle2D sb = screen.getBounds(); + Rectangle2D vb = screen.getVisualBounds(); + if (sb.getMinY() < vb.getMinY()) { + // 顶 + return new BoundingBox(sb.getMinX(), sb.getMinY(), sb.getWidth(), sb.getHeight() - vb.getHeight()); + } + if (vb.getMaxY() < sb.getMaxY()) { + // 底 + return new BoundingBox(sb.getMinX(), vb.getMaxY(), sb.getWidth(), sb.getHeight() - vb.getHeight()); + } + if (sb.getMinX() < vb.getMinX()) { + // 左 + return new BoundingBox(sb.getMinX(), sb.getMinY(), sb.getWidth() - vb.getWidth(), sb.getHeight()); + } + if (vb.getMaxX() < sb.getMaxX()) { + // 右 + return new BoundingBox(vb.getMaxX(), sb.getMinY(), sb.getWidth() - vb.getWidth(), sb.getHeight()); + } + return null; + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/SmoothScroll.java b/src/main/java/com/imyeyu/fx/utils/SmoothScroll.java new file mode 100644 index 0000000..cab5c87 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/SmoothScroll.java @@ -0,0 +1,372 @@ +package com.imyeyu.fx.utils; + +import com.sun.javafx.scene.control.VirtualScrollBar; +import javafx.animation.Animation; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.event.EventHandler; +import javafx.geometry.Bounds; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Control; +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.ComboBoxListViewSkin; +import javafx.scene.control.skin.VirtualFlow; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.util.Duration; +import com.imyeyu.fx.bean.Interpolates; +import com.imyeyu.java.bean.Callback; +import com.imyeyu.java.bean.CallbackArg; +import com.imyeyu.java.ref.Ref; + +import java.util.function.Function; + +/** + * 平滑滚动控制,使控件滚动平滑,此功能需要较多 GPU 性能 + * + *

注意:虚拟滚动面板需要通过列表项计算理论可滚动高度,所以列表项必须统一高度

+ * + *
+ *     SmoothScroll.textarea(new TextArea());                  // 文本域平滑滚动
+ *     SmoothScroll.scrollPane(new ScrollPane());              // 平滑滚动面板
+ *     SmoothScroll.virtual(new ListView());                   // 列表平滑滚动(虚拟面板平滑滚动,表格、树形结构等懒加载组件都是虚拟面板)
+ *     SmoothScroll.reflectSkin(new TextArea(), "scrollPane"); // 自定反射皮肤平滑滚动面板(反射组件 skin 里的滚动面板)
+ * 
+ * + * @author 夜雨 + * @since 2022-05-24 16:04 + */ +public class SmoothScroll { + + private static final double[] FRICTIONS = {.99, .1, .05, .04, .03, .02, .01, .04, .01, .008, .008, .008, .008, .0006, .0005, .00003, .00001}; + private static final double[] PUSHES = {.5}; + private static final double[] SCROLL_TO_FRICTIONS = Interpolates.EASE_OUT_QUINT.buildBezierPointValue(75); + + // ---------- 虚拟面板滚动(列表项需统一高度) ---------- + + + /** + * 平滑滚动 ComboBox 的选项列表 + * + * @param comboBox 选择器 + * @deprecated 1.3.4 过时,1.6.0 移除,请使用 {@link #comboBox(ComboBox)} + */ + @Deprecated + public static void combobox(ComboBox comboBox) { + comboBox(comboBox); + } + + /** + * 平滑滚动 ComboBox 的选项列表 + * + * @param comboBox 选择器 + */ + public static void comboBox(ComboBox comboBox) { + if (comboBox.getSkin() == null) { + comboBox.skinProperty().addListener((obs, o, obj) -> { + try { + virtual(Ref.getClassFieldValue(obj, ComboBoxListViewSkin.class, "listView", ListView.class)); + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException(e); + } + }); + } else { + try { + virtual(Ref.getClassFieldValue(comboBox.getSkin(), ComboBoxListViewSkin.class, "listView", ListView.class)); + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException(e); + } + } + } + + /** + * 反射控件虚拟滚动面板 + * + * @param control 控件 + */ + public static void virtual(Control control) { + if (control.getSkin() == null) { + control.skinProperty().addListener((obs, o, obj) -> { + try { + virtual(control, Ref.getFieldValue(obj, "flow", VirtualFlow.class)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + }); + } else { + try { + virtual(control, Ref.getFieldValue(control.getSkin(), "flow", VirtualFlow.class)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } + + /** + * 虚拟滚动面板平滑滚动 + * + * @param control 组件 + * @param virtualFlow 虚拟滚动面板 + */ + public static void virtual(Control control, VirtualFlow virtualFlow) { + try { + VirtualScrollBar vbar = Ref.getFieldValue(virtualFlow, "vbar", VirtualScrollBar.class); + DoubleProperty theoreticalHeight = new SimpleDoubleProperty(0); // 理论总高度 + DoubleProperty cellHeight = new SimpleDoubleProperty(0); // 行高 + + final double[] derivatives = new double[FRICTIONS.length]; + final double[] lastVPos = {0}; + + vbar.setUnitIncrement(5); + + // 列表项高度和理论高度计算 + Callback calcHeight = () -> { + if (0 < virtualFlow.getCellCount() && virtualFlow.getCell(0) != null) { + cellHeight.set(virtualFlow.getCell(0).getHeight()); + theoreticalHeight.set(virtualFlow.getCellCount() * cellHeight.get()); + } + }; + + Timeline timeline = new Timeline(); + final EventHandler dragHandler = event -> timeline.stop(); + final EventHandler scrollHandler = event -> { + calcHeight.handler(); + if (virtualFlow.getHeight() < theoreticalHeight.get()) { + vbar.valueProperty().set(lastVPos[0]); + if (event.getEventType() == ScrollEvent.SCROLL) { + double direction = 0 < event.getDeltaY() ? -1 : 1; + for (int j = 0; j < PUSHES.length; j++) { + derivatives[j] += direction * PUSHES[j]; + } + if (timeline.getStatus() == Animation.Status.STOPPED) { + timeline.play(); + } + } + } + event.consume(); + }; + Callback calcScrollKeyFrame = () -> { + timeline.stop(); + timeline.getKeyFrames().clear(); + timeline.getKeyFrames().add(new KeyFrame(Duration.millis(.6), e -> { + for (int j = 0; j < derivatives.length; j++) { + derivatives[j] *= FRICTIONS[j]; + } + for (int j = 1; j < derivatives.length; j++) { + derivatives[j] += derivatives[j - 1]; + } + double dy = derivatives[derivatives.length - 1]; + vbar.valueProperty().set(Math.min(Math.max(vbar.getValue() + dy / theoreticalHeight.get(), 0), 1)); + lastVPos[0] = vbar.getValue(); + if (Math.abs(dy) < 1) { + if (Math.abs(dy) < .001) { + timeline.stop(); + } + } + })); + }; + virtualFlow.heightProperty().addListener((obs, o, h) -> calcHeight.handler()); + virtualFlow.cellCountProperty().addListener((obs, o, cell) -> { + vbar.setValue(lastVPos[0]); + calcHeight.handler(); + calcScrollKeyFrame.handler(); + }); + calcScrollKeyFrame.handler(); + if (vbar.getParent() != null) { + vbar.getParent().addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler); + vbar.getParent().addEventHandler(ScrollEvent.ANY, scrollHandler); + } + vbar.setOnMouseReleased(e -> lastVPos[0] = vbar.getValue()); + vbar.parentProperty().addListener((obs, o, n) -> { + if (o != null) { + o.removeEventHandler(MouseEvent.DRAG_DETECTED, dragHandler); + o.removeEventHandler(ScrollEvent.ANY, scrollHandler); + } + if (n != null) { + n.addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler); + n.addEventHandler(ScrollEvent.ANY, scrollHandler); + } + }); + timeline.setCycleCount(Animation.INDEFINITE); + + // scrollTo 的动画实现 + Timeline scrollToTimeLine = new Timeline(); + CallbackArg buildScrollToFrame = index -> { + if (scrollToTimeLine.getStatus() == Animation.Status.RUNNING) { + scrollToTimeLine.stop(); + } + scrollToTimeLine.getKeyFrames().clear(); + + double fromValue = vbar.getValue(); + double offset = virtualFlow.getHeight() / cellHeight.get() * cellHeight.get(); + double toValue = cellHeight.get() * index / (theoreticalHeight.get() - offset); + if (1 <= toValue) { + toValue = lastVPos[0] = 1; + } + double between = toValue - fromValue; + lastVPos[0] = toValue; + if (vbar.getValue() == toValue) { + return; + } + + double keyMillis = 260D / SCROLL_TO_FRICTIONS.length; + KeyFrame frame; + for (int i = 0; i < SCROLL_TO_FRICTIONS.length; i++) { + double endValue; + if (between < 0) { + endValue = fromValue - Math.abs(between) * SCROLL_TO_FRICTIONS[i]; + } else { + endValue = fromValue + Math.abs(between) * SCROLL_TO_FRICTIONS[i]; + } + frame = new KeyFrame(Duration.millis(i * keyMillis), new KeyValue(vbar.valueProperty(), endValue)); + scrollToTimeLine.getKeyFrames().add(frame); + } + }; + + if (control instanceof TableView tableView) { + tableView.setOnScrollTo(e -> { + buildScrollToFrame.handler(e.getScrollTarget()); + scrollToTimeLine.play(); + }); + } + if (control instanceof ListView listView) { + listView.setOnScrollTo(e -> { + buildScrollToFrame.handler(e.getScrollTarget()); + scrollToTimeLine.play(); + }); + } + if (control instanceof TreeView treeView) { + treeView.setOnScrollTo(e -> { + buildScrollToFrame.handler(e.getScrollTarget()); + scrollToTimeLine.play(); + }); + } + if (control instanceof TreeTableView treeTableView) { + treeTableView.setOnScrollTo(e -> { + buildScrollToFrame.handler(e.getScrollTarget()); + scrollToTimeLine.play(); + }); + } + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + // ---------- 滚动面板 ---------- + + /** + * 文本域平滑滚动 + * + * @param textArea 文本域 + */ + public static void textarea(TextArea textArea) { + reflectSkin(textArea, "scrollPane"); + } + + /** + * 反射控件平滑滚动面板 + * + * @param control 控件 + * @param fieldName 反射字段 + */ + public static void reflectSkin(Control control, String fieldName) { + control.skinProperty().addListener((obs, o, skin) -> { + try { + scrollPaneV(Ref.getFieldValue(skin, fieldName, ScrollPane.class)); + } catch (IllegalAccessException e) { + throw new RuntimeException("not found reflect control ScrollPane", e); + } + }); + } + + /** + * 平滑滚动面板 + * + * @param scrollPane 滚动面板 + */ + public static void scrollPane(ScrollPane scrollPane) { + scrollPane(scrollPane, scrollPane.vvalueProperty(), Bounds::getHeight); + scrollPane(scrollPane, scrollPane.hvalueProperty(), Bounds::getWidth); + } + + /** + * 平滑垂直滚动面板 + * + * @param scrollPane 滚动面板 + */ + public static void scrollPaneV(ScrollPane scrollPane) { + scrollPane(scrollPane, scrollPane.vvalueProperty(), Bounds::getHeight); + } + + /** + * 平滑水平滚动面板 + * + * @param scrollPane 滚动面板 + */ + public static void scrollPaneH(ScrollPane scrollPane) { + scrollPane(scrollPane, scrollPane.hvalueProperty(), Bounds::getWidth); + } + + /** + * 平滑滚动面板 + * + * @param scrollPane 滚动面板 + * @param scrollProperty 滚动轴属性 + * @param sizeFunc 高度回调 + */ + public static void scrollPane(ScrollPane scrollPane, DoubleProperty scrollProperty, Function sizeFunc) { + final double[] derivatives = new double[FRICTIONS.length]; + + Timeline timeline = new Timeline(); + final EventHandler dragHandler = event -> timeline.stop(); + final EventHandler scrollHandler = event -> { + if (event.getEventType() == ScrollEvent.SCROLL) { + int direction = 0 < event.getDeltaY() ? -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((obs, o, n) -> { + if (o != null) { + o.removeEventHandler(MouseEvent.DRAG_DETECTED, dragHandler); + o.removeEventHandler(ScrollEvent.ANY, scrollHandler); + } + if (n != null) { + n.addEventHandler(MouseEvent.DRAG_DETECTED, dragHandler); + n.addEventHandler(ScrollEvent.ANY, scrollHandler); + } + }); + timeline.getKeyFrames().add(new KeyFrame(Duration.millis(.6), (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()); + scrollProperty.set(Math.min(Math.max(scrollProperty.get() + dy / size, 0), 1)); + if (Math.abs(dy) < .001) { + timeline.stop(); + } + })); + timeline.setCycleCount(Animation.INDEFINITE); + } +} diff --git a/src/main/java/com/imyeyu/fx/utils/StringConverters.java b/src/main/java/com/imyeyu/fx/utils/StringConverters.java new file mode 100644 index 0000000..e61a44d --- /dev/null +++ b/src/main/java/com/imyeyu/fx/utils/StringConverters.java @@ -0,0 +1,68 @@ +package com.imyeyu.fx.utils; + +import javafx.util.StringConverter; +import com.imyeyu.java.ref.Ref; +import com.imyeyu.utils.Time; + +import java.text.ParseException; +import java.time.LocalDate; + +/** + * FX 常用字符串转换 + * + * @author 夜雨 + * @since 2023-02-01 19:51 + */ +public class StringConverters { + + /** 日期转换 */ + public static StringConverter DATE = new StringConverter<>() { + + @Override + public String toString(LocalDate date) { + if (date == null) { + return null; + } else { + return Time.toDate(Time.fromLocalDate(date)); + } + } + + @Override + public LocalDate fromString(String string) { + try { + return Time.toLocalDateTime(Time.date.parse(string).getTime()).toLocalDate(); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + }; + + /** + * 通用枚举转换,此转换反射枚举项的 name 字段 + * + * @param enumClass 枚举类 + * @return 转换器 + * @param 枚举类型 + */ + public static > StringConverter type(Class enumClass) { + return new StringConverter<>() { + + @Override + public String toString(T object) { + if (object == null) { + return ""; + } + try { + return Ref.getFieldValue(object, "name", String.class); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + @Override + public T fromString(String string) { + return Ref.toType(enumClass, string); + } + }; + } +} \ No newline at end of file