快速创建基于JavaFX的桌面App

快速创建基于JavaFX的桌面App

快速创建一个基于JavaFX的桌面APP,用于模拟一个简单的客户端POST请求;
项目基于Maven创建,使用的IDE是Intellij IDEA,项目编码全部为UTF-8;
JDK版本为1.8。

制作目标

简单说明一下想制作的APP的样子:
实现一个APP,存在两个视图:首页和编辑页。首页简单的展示一些欢迎话语,编辑页负责填写POST请求的数据,并且有一个按钮提交请求。
顶部有菜单,部分菜单只在编辑视图中有效,两个视图间的切换带有动画效果。
下面,开始实现这个简单的APP。

一、创建项目

  1. 创建新目录,选择Maven项目,原型选择maven-archetype-quickstart;
  2. 添加项目依赖,说明如下:
1. 由于个人偏好使用RxJava,而且JavaFx的设计也包含对RxJava的使用,javafx有一个配套的框架,所以会引用rxjavafx框架;
2. 要实现的顶部菜单不在视图范围内,需要实现一个类似EventBus的功能,由顶部菜单向下传播点击操作的事件,因此会引入rxjava2中的部分功能;
3. controlsfx中提供了很多使用的自定义控件;
4. 控件样式直接使用基于Material Design的jfoenix;
5. datafx可以帮助我快速的管理各个视图和菜单的切换关系;
6. json解析库使用fasterxml.jackson,http请求使用apache的httpcomponents,也会使用到其他的commons库;
7. zenjava可以帮助快速打包项目为执行的APP;
8. 使用log4j记录执行日志。

项目依赖添加后部分内容如下:

<properties>
  <project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
  <fasterxml.jackson.version>2.8.8fasterxml.jackson.version>
properties>

<dependencies>
  <dependency>
    <groupId>org.controlsfxgroupId>
    <artifactId>controlsfxartifactId>
    <version>8.40.12version>
  dependency>
  <dependency>
    <groupId>org.slf4jgroupId>
    <artifactId>slf4j-log4j12artifactId>
    <version>1.7.25version>
  dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.coregroupId>
    <artifactId>jackson-coreartifactId>
    <version>${fasterxml.jackson.version}version>
  dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.coregroupId>
    <artifactId>jackson-databindartifactId>
    <version>${fasterxml.jackson.version}version>
  dependency>
  <dependency>
    <groupId>com.fasterxml.jackson.coregroupId>
    <artifactId>jackson-annotationsartifactId>
    <version>${fasterxml.jackson.version}version>
  dependency>
  <dependency>
    <groupId>org.apache.httpcomponentsgroupId>
    <artifactId>httpclientartifactId>
    <version>4.5.3version>
  dependency>
  <dependency>
    <groupId>io.reactivex.rxjava2groupId>
    <artifactId>rxjavaartifactId>
    <version>2.0.6version>
  dependency>
  <dependency>
    <groupId>io.reactivexgroupId>
    <artifactId>rxjavafxartifactId>
    <version>2.0.2version>
  dependency>
  <dependency>
    <groupId>com.jfoenixgroupId>
    <artifactId>jfoenixartifactId>
    <version>1.4.0version>
  dependency>
  <dependency>
    <groupId>io.datafxgroupId>
    <artifactId>flowartifactId>
    <version>8.0.1version>
  dependency>
dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.pluginsgroupId>
      <artifactId>maven-compiler-pluginartifactId>
      <configuration>
        <source>1.8source>
        <target>1.8target>
        <encoding>utf-8encoding>
      configuration>
    plugin>
    <plugin>
      <groupId>com.zenjavagroupId>
      <artifactId>javafx-maven-pluginartifactId>
      <version>8.5.0version>
      <configuration>
        <vendor>snartvendor>
        <mainClass>demo.AppmainClass>
      configuration>
    plugin>
  plugins>
build>

maven直接创建的项目没有resources目录,因此需要在project setting中添加resources目录。

二、创建视图

javafx项目的入口只需要实现javafx.application.Application类即可

public class App extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception {
        // TODO
    }
}

使用datafx管理视图的切换(datafx说明见: https://github.com/guigarage/DataFX ),首先需要在start方法中初始化一个ViewFlowContext来管理视图以及视图间共享的数据模型

ViewFlowContext flowContext = new ViewFlowContext();

Javafx用Stage(舞台)来做根基,用Scene(场景)来控制每个场景,每个场景可以定义要展示的内容,这里需要先将primaryStage注入到ViewFlowContext中,初始化一个视图流Flow,然后初始化一个Scene,每个Scene的初始化都需要一个root控件,这里我们使用jfoenix中的JFXDecorator(jfoenix说明见: https://github.com/jfoenixadmin/JFoenix )

flowContext.register("Stage", primaryStage);

// create flow and flow container, flow container controls view decoration and view exchange
Flow flow = new Flow(MainController.class);
DefaultFlowContainer container = new DefaultFlowContainer();
flow.createHandler(flowContext).start(container);

// JFXDecorator will be applied to primaryStage, and decorated on view which is created by flow container
JFXDecorator decorator = new JFXDecorator(primaryStage, container.getView(),
    false, true, true);

// init scene with a decorator
Scene scene = new Scene(decorator, 750, 500);
primaryStage.setMinWidth(500);
primaryStage.setMinHeight(400);
primaryStage.setTitle("Demo");
primaryStage.setScene(scene);
primaryStage.show();

Flow的初始化需要一个默认的视图以及视图控制器:

import io.datafx.controller.ViewController;

@ViewController(value = "/views/main.fxml")
public class MainController {
}

注解@ViewController用于定义此控制器指定的视图位置,main.fxml定义在resource/views下:








<BorderPane xmlns="http://javafx.com/javafx/8.0.112"
            xmlns:fx="http://javafx.com/fxml/1">
  <top>
    <HBox prefHeight="10.0" BorderPane.alignment="CENTER">
      <MenuBar prefHeight="25.0" HBox.hgrow="ALWAYS">
        <Menu mnemonicParsing="false" text="菜单">
          <MenuItem mnemonicParsing="false" text="主页"/>
          <MenuItem mnemonicParsing="false" text="发送请求"/>
        Menu>
        <Menu mnemonicParsing="false" text="编辑">
          <MenuItem mnemonicParsing="false" text="保存"/>
        Menu>
      MenuBar>
    HBox>
  top>
  <center>
  center>
BorderPane>

main.fxml 定义了一个BorderPane,BorderPane是一个边框面板,存在上下左右中四个域,是一个非常适合做菜单的面板,我在上(top)定义了一个水平盒子(HBox),用于将菜单栏放在其中,菜单栏中定义了两个主菜单:菜单和编辑;
运行App,可以看到一个只有菜单的窗口,它的顶部是一个黑色的边框,上面有最小化、最大化和关闭三个通用的控件,这些控件的控制是在JFXDecorator初始化时定义的,可以查看下此类的构造函数的说明;
但是我不太喜欢这种黑色的边框,以及菜单的样式,因此下面我会将这些内容重新定义一番。

三、重新设计Decorator和Menu

默认的App顶部不显示Title和Icon,双击顶部栏也不会触发窗口最大化,下面我要在顶部栏添加这些控件,由于jfoenix没有提供类似的控件,也没有实现此效果的接口,因此需要一点投机取巧的方式:

1. 首先重新实现Decorator

public class CustomJFXDecorator extends JFXDecorator {
    public CustomJFXDecorator(Stage stage, Node node) {
        this(stage, node, true, true, true);
    }

    public CustomJFXDecorator(Stage stage, Node node, boolean fullScreen, boolean max, boolean min) {
        super(stage, node, fullScreen, max, min);
        // top area is a buttons container and with a class 'jfx-decorator-buttons-container'
        Node btnContainerOpt = this.lookup(".jfx-decorator-buttons-container");
        if (btnContainerOpt != null) {
            // buttons container is a HBox
            final HBox buttonsContainer = (HBox) btnContainerOpt;

            // check and get the index of maximum button
            ObservableList buttons = buttonsContainer.getChildren();
            int btnMaxIdx = 0;
            if (fullScreen) {
                btnMaxIdx++;
            }
            if (min) {
                btnMaxIdx++;
            }
            if (buttons.size() >= btnMaxIdx) {
                final JFXButton btnMax = (JFXButton) buttons.get(btnMaxIdx);
                // set max button triggered when buttons container is double clicked
                buttonsContainer.setOnMouseClicked(event -> {
                    if (event.getClickCount() == 2) {
                        btnMax.fire();
                    }
                });
            }

            // add HBox in the left of buttons container
            HBox leftBox = new HBox();
            leftBox.setAlignment(Pos.CENTER_LEFT);
            leftBox.setPadding(new Insets(0, 0, 0, 10));
            leftBox.setSpacing(10);

            // add icon in the left of HBox
            HBox iconBox = new HBox();
            iconBox.setAlignment(Pos.CENTER_LEFT);
            iconBox.setSpacing(5);

            // bind icon
            stage.getIcons().addListener((ListChangeListener) c -> {
                while (c.next()) {
                    iconBox.getChildren().clear();
                    ObservableList icons = c.getList();
                    if (icons != null && !icons.isEmpty()) {
                        ImageView imageView;
                        for (Image icon : icons) {
                            imageView = new ImageView();
                            imageView.setFitWidth(20);
                            imageView.setFitHeight(20);
                            imageView.setImage(icon);
                            iconBox.getChildren().add(imageView);
                        }
                    }
                }
            });

            // bind title
            Label title = new Label();
            title.textProperty().bindBidirectional(stage.titleProperty());
            // set title to white because of the black background
            title.setTextFill(Paint.valueOf("#fdfdfd"));

            leftBox.getChildren().addAll(iconBox, title);

            HBox.setHgrow(leftBox, Priority.ALWAYS);
            buttonsContainer.getChildren().add(0, leftBox);
        }
    }
}

控件主要的目的就是将顶部栏的双击事件和最大化窗口按钮绑定,在栏目的左侧添加一个icon域和title域。然后再App中使用新的Decorator:

CustomJFXDecorator decorator = new CustomJFXDecorator(primaryStage,     container.getView(), false, true, true);

添加primaryStage的icon,icon放在resources/image目录下:

primaryStage.getIcons().add(new Image("/image/icon.png"));

现在看起来稍微好点了,但是顶部太黑了,而且窗口两侧黑色区域太大,为了修改这些样式,需要自定义样式,javafx中可以通过css控制控件的样式。

2. 修改主框架样式

首先添加一个主样式文件 main.css, 放在目录 resources/css 下,然后查阅了相关资料得知修改decorator样式的css类名,修改如下:

/* jfx decorator part, about to change window style */
.jfx-decorator {
    -fx-decorator-color: derive(#3f3f3f, -20%);
}

.jfx-decorator .jfx-decorator-buttons-container {
    -fx-background-color: -fx-decorator-color;
}

.jfx-decorator .resize-border {
    -fx-border-color: -fx-decorator-color;
    -fx-border-width: 0 1 1 1;
}

然后将此样式应用到项目中,在App中添加:

final ObservableList stylesheets = scene.getStylesheets();
stylesheets.add(App.class.getResource("/css/main.css").toExternalForm());

3. 修改菜单样式

菜单上,给每个Item添加一个快捷键,同时稍微调整下菜单的显示。
首先给MenuBar添加styleClass属性,给每个Item添加快捷方式:

<MenuBar styleClass="top-menu" prefHeight="25.0" HBox.hgrow="ALWAYS">
  <Menu mnemonicParsing="false" text="菜单">
    <MenuItem mnemonicParsing="false" text="主页">
      <accelerator>
        <KeyCodeCombination alt="UP" code="H" control="DOWN" meta="UP" shift="UP" shortcut="UP"/>
      accelerator>
    MenuItem>
    <MenuItem mnemonicParsing="false" text="发送请求">
      <accelerator>
        <KeyCodeCombination alt="UP" code="R" control="DOWN" meta="UP" shift="UP" shortcut="UP"/>
      accelerator>
    MenuItem>
  Menu>
  <Menu mnemonicParsing="false" text="编辑">
    <MenuItem mnemonicParsing="false" text="保存">
      <accelerator>
        <KeyCodeCombination alt="UP" code="S" control="DOWN" meta="UP" shift="UP" shortcut="UP"/>
      accelerator>
    MenuItem>
  Menu>
MenuBar>

然后开始在main.css中定义样式,选中时字体颜色不变白,去掉菜单和底部的空白间隙,菜单每个Item的宽度最小为10em,Item后面的快捷键标签显示为灰色:

/* top menu part, change menu style */
.top-menu .label {
    -fx-text-fill: #2d2d2d;
}

.top-menu .menu-item {
    -fx-padding: 0.3em 1em 0.3em 1em;
}

.top-menu .context-menu {
    -fx-min-width: 10em;
    -fx-padding: 0.1em 0 0 0;
}

.top-menu .menu-item .label {
    -fx-text-fill: #2d2d2d;
}

.top-menu .menu-item .accelerator-text {
    -fx-text-fill: grey;
    -fx-padding: 0em 0em 0em 3em;
}

以上,样式的自定义就完成了。

四、定义视图

上面的 main.fxml 主要定义整个App的主展示,包含一个顶部菜单和一个中间展示域,需要变动展示的区域即为中间展示域,下面我们通过在MainController初始化datafx需要控制的 view flow (视图流)

1.定义首页

首先进入App需要有一个首页,定义HomeController 和 home.fxml

HomeController.java

@ViewController(value = "/views/home.fxml")
public class HomeController {
}

home.fxml






<StackPane xmlns="http://javafx.com/javafx"
           xmlns:fx="http://javafx.com/fxml">
  <HBox alignment="CENTER">
    <Label textFill="#3e8080">欢迎使用Demo!Label>
  HBox>
StackPane>

首页只是简单地展示一个欢迎语;
然后将此视图添加入视图流,为了将菜单和视图流绑定,需要给每个fxml中的控件添加id(在需要添加的控件上添加属性fx:id),在MainController中引用这些控件(使用@FXML注解,字段名即为设置的id)

添加id

"false" text="菜单">
  "home" mnemonicParsing="false" text="主页">
    ...
  
  "demo" mnemonicParsing="false" text="发送请求">
    ...
  

"false" text="编辑">
  "save" mnemonicParsing="false" text="保存">
    ...
  

引用控件并注入ViewFlowContext

public class MainController {
    @FXMLViewFlowContext
    private ViewFlowContext context;
    @FXML
    private BorderPane root;
    @FXML
    private MenuItem home;
    @FXML
    private MenuItem demo;
    @FXML
    private MenuItem save;
}

为了实现动画切换的效果,需要重新实现一个自定义动画效果(这里使用ContainerAnimations已经定义好的动画)和动画时长的FlowContainer,动画效果实现的方式就是在加载新的视图之前,对原来的区域内容截图并设置动画效果,具体实现如下:

public class ExtendedAnimatedFlowContainer extends AnimatedFlowContainer implements FlowContainer<StackPane> {

    private final StackPane view;
    private final Duration duration;
    private Function> animationProducer;
    private Timeline animation;
    private final ImageView placeholder;

    /**
     * Defaults constructor that creates a container with a fade animation that last 320 ms.
     */
    public ExtendedAnimatedFlowContainer() {
        this(Duration.millis(320));
    }

    /**
     * Creates a container with a fade animation and the given duration.
     *
     * @param duration the duration of the animation
     */
    public ExtendedAnimatedFlowContainer(Duration duration) {
        this(duration, ContainerAnimations.FADE);
    }

    /**
     * Creates a container with the given animation type and  duration.
     *
     * @param duration  the duration of the animation
     * @param animation the animation type
     */
    public ExtendedAnimatedFlowContainer(Duration duration, ContainerAnimations animation) {
        this(duration, animation.getAnimationProducer());
    }

    /**
     * Creates a container with the given animation type and duration.
     *
     * @param duration          the duration of the animation
     * @param animationProducer the {@link KeyFrame} instances that define the animation
     */
    public ExtendedAnimatedFlowContainer(Duration duration, Function>
        animationProducer) {
        this.view = new StackPane();
        this.duration = duration;
        this.animationProducer = animationProducer;
        placeholder = new ImageView();
        placeholder.setPreserveRatio(true);
        placeholder.setSmooth(true);
    }

    public void changeAnimation(ContainerAnimations animation) {
        this.animationProducer = animation.getAnimationProducer();
    }

    @Override
    public  void setViewContext(ViewContext context) {
        updatePlaceholder(context.getRootNode());
        if (animation != null) {
            animation.stop();
        }
        animation = new Timeline();
        animation.getKeyFrames().addAll(animationProducer.apply(this));
        animation.getKeyFrames().add(new KeyFrame(duration, (e) -> clearPlaceholder()));
        animation.play();
    }

    /**
     * Returns the {@link ImageView} instance that is used as a placeholder for the old view in each navigation
     * animation.
     *
     * @return image view place holder
     */
    public ImageView getPlaceholder() {
        return placeholder;
    }

    /**
     * Returns the duration for the animation.
     *
     * @return the duration for the animation
     */
    public Duration getDuration() {
        return duration;
    }

    public StackPane getView() {
        return view;
    }

    private void clearPlaceholder() {
        view.getChildren().remove(placeholder);
    }

    private void updatePlaceholder(Node newView) {
        if (view.getWidth() > 0 && view.getHeight() > 0) {
            SnapshotParameters parameters = new SnapshotParameters();
            parameters.setFill(Color.TRANSPARENT);
            Image placeholderImage = view.snapshot(parameters,
                new WritableImage((int) view.getWidth(), (int) view.getHeight()));
            placeholder.setImage(placeholderImage);
            placeholder.setFitWidth(placeholderImage.getWidth());
            placeholder.setFitHeight(placeholderImage.getHeight());
        } else {
            placeholder.setImage(null);
        }
        placeholder.setVisible(true);
        placeholder.setOpacity(1.0);
        view.getChildren().setAll(placeholder, newView);
        placeholder.toFront();
    }
}

在MainController初始化时初始化视图流控制root的中间域的切换展示:

@PostConstruct
public void init() throws FlowException {
    Objects.requireNonNull(context);
    // create the inner flow and content, set the default controller
    Flow innerFlow = new Flow(HomeController.class);

    final FlowHandler flowHandler = innerFlow.createHandler(context);
    context.register("ContentFlowHandler", flowHandler);
    context.register("ContentFlow", innerFlow);
    final Duration containerAnimationDuration = Duration.millis(320);
    root.setCenter(flowHandler.start(new ExtendedAnimatedFlowContainer(containerAnimationDuration,
        ContainerAnimations.SWIPE_LEFT)));
    context.register("ContentPane", root.getCenter());

    // bind events on menu
    JavaFxObservable.actionEventsOf(home).subscribe(actionEvent -> {
        if (!(flowHandler.getCurrentView().getViewContext().getController() instanceof HomeController)) {
            flowHandler.handle(home.getId());
        }
    });

    // bind menu to view in flow
    bindMenuToController(home, HomeController.class, innerFlow);
}

private void bindMenuToController(MenuItem menu, Class controllerClass, Flow flow) {
    flow.withGlobalLink(menu.getId(), controllerClass);
}

2. 定义编辑视图

下面添加编辑视图(填写Post数据),假设有一个信息保存接口,需要提供一个信息类型、名称和说明:

demo.fxml













<BorderPane fx:id="root" xmlns="http://javafx.com/javafx/8.0.112" xmlns:fx="http://javafx.com/fxml/1">
  <center>
    <VBox fx:id="infoForm" alignment="TOP_CENTER" spacing="40">
      <HBox spacing="10">
        <Label>类型Label>
        <JFXRadioButton fx:id="radioFood" selected="true" text="食物">
          <toggleGroup>
            <ToggleGroup fx:id="typeGroup"/>
          toggleGroup>
          <cursor>
            <Cursor fx:constant="HAND"/>
          cursor>
        JFXRadioButton>
        <JFXRadioButton fx:id="radioTool" text="工具" toggleGroup="$typeGroup">
          <cursor>
            <Cursor fx:constant="HAND"/>
          cursor>
        JFXRadioButton>
      HBox>

      <JFXTextField fx:id="nameField" labelFloat="true" promptText="名称">
      JFXTextField>

      <JFXTextField fx:id="descField" labelFloat="true" promptText="说明">
      JFXTextField>

      <padding>
        <Insets bottom="20.0" left="10.0" right="10.0" top="10.0"/>
      padding>
    VBox>
  center>
  <bottom>
    <HBox prefHeight="70" alignment="CENTER_LEFT">
      <JFXTextField fx:id="urlField" promptText="链接" labelFloat="true"
                    HBox.hgrow="ALWAYS" maxWidth="1000">
      JFXTextField>
      <HBox HBox.hgrow="ALWAYS" alignment="CENTER_RIGHT">
        <JFXButton fx:id="postBtn" buttonType="RAISED" prefWidth="100" styleClass="jfx-button-primary" text="提交"/>
      HBox>
      <padding>
        <Insets left="10.0" bottom="10.0" right="10.0" top="10.0"/>
      padding>
    HBox>
  bottom>
BorderPane>

定义一个Model,通过JavaFx的PropertyBinding功能,直接将Model的字段和输入控件绑定,点击提交按钮时发出Post请求,HttpsClient是一个对 CloseableHttpClient 的简单封装:

DemoController

@ViewController("/views/demo.fxml")
public class DemoController {
    private static Logger logger = LoggerFactory.getLogger(DemoController.class.getName());

    @FXML
    private ToggleGroup typeGroup;
    @FXML
    private JFXRadioButton radioFood, radioTool;
    @FXML
    private JFXTextField nameField, descField, urlField;
    @FXML
    private JFXButton postBtn;

    private DemoInfo demoInfo;

    private static final int TYPE_FOOD = 1;
    private static final int TYPE_TOOL = 2;

    @PostConstruct
    public void init() {
        demoInfo = new DemoInfo();

        bindDemoInfoToControls();

        JavaFxObservable.actionEventsOf(postBtn)
            .subscribeOn(Schedulers.computation())
            .subscribe(actionEvent -> {
                // runnable for that thread
                new Thread(this::submit).start();
            });
    }

    private void bindDemoInfoToControls() {
        radioFood.setUserData(TYPE_FOOD);
        radioTool.setUserData(TYPE_TOOL);

        demoInfo.typeProperty().addListener((observable, oldValue, newValue) -> {
            Toggle selected = null;
            for (Toggle toggle : typeGroup.getToggles()) {
                if (newValue == toggle.getUserData()) {
                    selected = toggle;
                    break;
                }
            }
            if (selected == null) {
                throw new IllegalArgumentException("Demo info set type value which is not in toggle values");
            }

            typeGroup.selectToggle(selected);
        });

        typeGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> demoInfo
            .typeProperty().setValue((Integer) newValue.getUserData()));

        nameField.textProperty().bindBidirectional(demoInfo.nameProperty());
        descField.textProperty().bindBidirectional(demoInfo.descriptionProperty());
    }

    private void submit() {
        try {
            ObjectMapper om = new ObjectMapper();
            String postData = om.writeValueAsString(demoInfo);
            String url = urlField.getText();
            HttpResponse response = HttpsClient.doPostSSL(url, postData);
            if (response == null) {
                return;
            }
            if (response.getStatus() != HttpStatus.SC_OK) {
                Exception exception = response.getException();
                if (exception != null && exception instanceof HttpHostConnectException) {
                    return;
                }
                return;
            }
            logger.info("Post success");
        } catch (JsonProcessingException e) {
            logger.error("Do post parse post data error", e);
        }
    }
}

在MainController中将视图添加进视图流

JavaFxObservable.actionEventsOf(demo).subscribe(actionEvent -> {
    if (!(flowHandler.getCurrentView().getViewContext().getController() instanceof DemoController)) {
        flowHandler.handle(demo.getId());
    }
});
bindMenuToController(demo, DemoController.class, innerFlow);

然而,上面的操作流程无法看到请求是否在进行,以及请求的结果,所以将在流程中添加如下操作:当点击提交时,出现一个spinner,请求返回时,弹窗提醒结果。
对于现实的spinner,使用jfoenix的JFXSpinner,并将它展示一个模态框中,实现如下:

public class DemoController {
    ...
    @FXMLViewFlowContext
    private ViewFlowContext context;
    @FXML
    private BorderPane root;

    ...

    private Stage spinnerStage;

    ...

    private void initSpinner() {
        StackPane spinnerRoot = new StackPane();
        spinnerRoot.getStyleClass().add("register-dialog");
        JFXSpinner first = new JFXSpinner();
        first.getStyleClass().addAll("spinner-black", "first-spinner");
        first.setStartingAngle(-40);
        JFXSpinner second = new JFXSpinner();
        second.getStyleClass().addAll("spinner-dark", "second-spinner");
        second.setStartingAngle(-90);
        JFXSpinner third = new JFXSpinner();
        third.getStyleClass().addAll("spinner-gray", "third-spinner");
        third.setStartingAngle(-120);
        spinnerRoot.getChildren().addAll(first, second, third);

        spinnerStage = new Stage(StageStyle.TRANSPARENT);
        spinnerStage.initModality(Modality.APPLICATION_MODAL);
        spinnerStage.initOwner((Stage) context.getRegisteredObject("Stage"));
        Scene scene = new Scene(spinnerRoot, Color.TRANSPARENT);
        scene.getStylesheets().add(DemoController.class
            .getResource("/css/register-dialog.css").toExternalForm());
        spinnerStage.setScene(scene);
    }

    private void showSpinner() {
        Stage primaryStage = (Stage) context.getRegisteredObject("Stage");

        spinnerStage.setWidth(primaryStage.getWidth());
        spinnerStage.setHeight(primaryStage.getHeight());

        spinnerStage.setX(primaryStage.getX());
        spinnerStage.setY(primaryStage.getY());
        spinnerStage.show();
    }

    private void closeSpinner(Runnable later) {
        Platform.runLater(() -> {
            spinnerStage.close();
            if (later != null) {
                later.run();
            }
        });
    }
}

修改submit方法

@PostConstruct
public void init() {
    demoInfo = new DemoInfo();

    bindDemoInfoToControls();
    initSpinner();

    JavaFxObservable.actionEventsOf(postBtn)
        .subscribeOn(Schedulers.computation())
        .subscribe(actionEvent -> {
            showSpinner();
            // runnable for that thread
            new Thread(this::submit).start();
        });
}

private void submit() {
    try {
        ObjectMapper om = new ObjectMapper();
        String postData = om.writeValueAsString(demoInfo);
        String url = urlField.getText();
        HttpResponse response = HttpsClient.doPostSSL(url, postData);
        if (response == null) {
            closeSpinner(() -> NotificationUtils.notifyError("提交失败!", root));
            return;
        }
        if (response.getStatus() != HttpStatus.SC_OK) {
            Exception exception = response.getException();
            if (exception != null && exception instanceof HttpHostConnectException) {
                closeSpinner(() -> NotificationUtils.notifyError("无法连接服务器!", root));
                return;
            }
            closeSpinner(() -> NotificationUtils.notifyError("提交失败!", root));
            return;
        }
        closeSpinner(() -> NotificationUtils.notifySuccess("提交成功!", root));
    } catch (JsonProcessingException e) {
        logger.error("Do post parse post data error", e);
        closeSpinner(() -> NotificationUtils.notifyError("无法解析请求JSON:" + e.getMessage(), root));
    }
}

以上,简单的编辑框就实现了。

五、实现保存操作

菜单上的编辑->保存按钮还没有实现,这个按钮的目的是在进入编辑视图时,点击保存时能将当前填写的信息保存到本地中,下次进入此视图的时候会检查本地是否有缓存,如果存在,则读取缓存。但是,当不在编辑视图时,保存按钮需要被禁用(同时置灰)。
为了实现保存按钮只在编辑视图中有效,需要做以下操作:

在MainController中将save菜单的disableProperty和视图绑定

public class MainController {
    ...
    private BooleanProperty saveDisable = new SimpleBooleanProperty();

    @PostConstruct
    public void init() throws FlowException {
        ...
        JavaFxObservable.actionEventsOf(home).subscribe(actionEvent -> {
            if (!(flowHandler.getCurrentView().getViewContext().getController() instanceof HomeController)) {
                flowHandler.handle(home.getId());
                saveDisable.setValue(Boolean.TRUE);
            }
        });
        JavaFxObservable.actionEventsOf(demo).subscribe(actionEvent -> {
            if (!(flowHandler.getCurrentView().getViewContext().getController() instanceof DemoController)) {
                flowHandler.handle(demo.getId());
                saveDisable.setValue(Boolean.FALSE);
            }
        });
        ...
        saveDisable.setValue(Boolean.TRUE);
        save.disableProperty().bindBidirectional(saveDisable);
    }
}

实现cache的读写,首先需要实现EventBus:

Event

public class Event {
    private EventType type;

    public Event(EventType type) {
        this.type = type;
    }

    public EventType getType() {
        return type;
    }

    public void setType(EventType type) {
        this.type = type;
    }

    public static enum EventType {
        SAVE
    }
}

EventBus

public class EventBus {
    private static final EventBus INSTANCE = new EventBus();

    private final PublishSubject mBusSubject = PublishSubject.create();

    public static EventBus getInstance() {
        return INSTANCE;
    }

    public Disposable register(Consumer onNext) {
        return mBusSubject.subscribe(onNext);
    }

    public void postSave(Event event) {
        mBusSubject.onNext(event);
    }
}

在MainController中设置当点击保存时触发事件:

 JavaFxObservable.actionEventsOf(save).subscribe(actionEvent -> {
    EventBus.getInstance().postSave(new Event(Event.EventType.SAVE));
});

在DemoController中定义事件接收操作以及缓存相关操作:

public class DemoController {
    ...
    private Disposable disposable;
    ...
    private static final String CACHE_PATH = "data" + File.separator + "info.data";

    @PostConstruct
    public void init() {
        ...
        initFields();
        ...
        disposable = EventBus.getInstance().register(event -> {
            if (event.getType() == Event.EventType.SAVE) {
                doSave();
            }
        });
    }

    @PreDestroy
    public void destroy() {
        if (disposable != null) {
            disposable.dispose();
        }
    }

    ...

    private void initFields() {
        DemoInfoCache cache = readCache();
        if (cache != null) {
            DemoInfo savedInfo = cache.getDemoInfo();
            this.demoInfo.setType(savedInfo.getType());
            this.demoInfo.setName(savedInfo.getName());
            this.demoInfo.setDescription(savedInfo.getDescription());
            this.urlField.setText(cache.getUrl());
        }
    }

    private void doSave() {
        final DemoInfoCache cache = new DemoInfoCache();
        cache.setDemoInfo(this.demoInfo);
        cache.setUrl(this.urlField.getText());

        ObjectMapper om = new ObjectMapper();
        Path localCache = Paths.get(CACHE_PATH);
        Path localCacheDir = localCache.getParent();
        OutputStream os = null;
        try {
            if (!Files.exists(localCacheDir) || !Files.isDirectory(localCacheDir)) {
                Files.createDirectory(localCacheDir);
            }
            os = new FileOutputStream(CACHE_PATH);

            byte[] saveObjBytes = om.writeValueAsBytes(cache);
            byte[] saveFileBytes = Base64.getEncoder().encode(saveObjBytes);

            os.write(saveFileBytes);
            os.flush();
        } catch (IOException e) {
            logger.error("Save post data to local file failed, error", e);
            NotificationUtils.notifyError("保存失败!", root);
        } finally {
            if (os != null) {
                try {
                    os.close();
                } catch (IOException ignored) {
                }
            }
        }
    }

    private DemoInfoCache readCache() {
        Path path = Paths.get(CACHE_PATH);
        if (Files.isRegularFile(path)) {
            try (InputStream is = Files.newInputStream(path)) {
                byte[] localCacheBytes = new byte[is.available()];
                int i = is.read(localCacheBytes);
                logger.debug("read total {} bytes of register cache", i);
                byte[] decrypt = Base64.getDecoder().decode(localCacheBytes);
                ObjectMapper om = new ObjectMapper();
                return om.readValue(decrypt, DemoInfoCache.class);
            } catch (Exception e) {
                logger.error("read register cache failed", e);
            }
        }
        return null;
    }
}

至此,一个简单的demo桌面app就完成了,项目地址见:https://github.com/blacksider/javafx-demo

你可能感兴趣的:(Java)