JavaFx实现图片浏览器

1,274 阅读4分钟

开局一张图

image.png

image.png

实现的功能有

  • 拖动查看
  • 旋转
  • 点击缩放及鼠标滚轮/触控板缩放
  • 选择多图、文件夹时,上/下页切换

0、代码结构

├── java
│   └── com
│       └── wnewey
│           ├── ImgViewer.java # 入口类
│           ├── commponet
│           │   └── draglistener # 拖动相关类
│           │       ├── DragListener.java 
│           │       ├── DragListenerCall.java
│           │       └── caller
│           │           └── ImgViewerImgViewPaneDragListenerCall.java
│           ├── controller # 展示及控制相关类
│           │   └── imgviewer
│           │       ├── ImgViewerController.java
│           │       ├── ImgViewerView.java
│           │       └── ImgViewerWindow.java
│           ├── kit # 工具类
│           │   ├── PathKit.java
│           │   └── StrKit.java
│           ├── util # 工具类
│           │   ├── FileChooserUtil.java
│           │   └── ImageUtil.java
│           └── vo  # VO对象
│               └── ImageSize.java
└── resources
    └── view # FXML布局文件
        └── ImgViewerView.fxml

1、界面设计

1.1 使用FXML完成大致布局

<!-- imgviewer.fxml -->
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.layout.*?>
<AnchorPane xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1"
            fx:id="panel" fx:controller="com.wnewey.controller.imgviewer.ImgViewerController">
    <!-- 菜单 -->        
    <MenuBar fx:id="menuBar" useSystemMenuBar="true">
        <Menu text="文件">
            <MenuItem text="打开文件" onAction="#openImgFile"/>
            <MenuItem text="打开文件夹" onAction="#openImgFold"/>
        </Menu>
    </MenuBar>
    
     <!-- 图片显示部分 -->
    <GridPane alignment="CENTER">
        <AnchorPane.topAnchor>0</AnchorPane.topAnchor>
        <AnchorPane.leftAnchor>0</AnchorPane.leftAnchor>
        <AnchorPane.rightAnchor>0</AnchorPane.rightAnchor>
        <AnchorPane.bottomAnchor>0</AnchorPane.bottomAnchor>
        <HBox alignment="CENTER" fx:id="imgViewPane" visible="false">
            <ImageView fx:id="imageView"/>
            <Label fx:id="tipsLabel" visible="false"/>
        </HBox>
    </GridPane>
   
    <!-- 初始打开时显示打开文件按钮 -->
    <GridPane fx:id="fileBtns" alignment="CENTER">
        <AnchorPane.topAnchor>0</AnchorPane.topAnchor>
        <AnchorPane.leftAnchor>0</AnchorPane.leftAnchor>
        <AnchorPane.rightAnchor>0</AnchorPane.rightAnchor>
        <AnchorPane.bottomAnchor>0</AnchorPane.bottomAnchor>
        <HBox alignment="CENTER" spacing="100">
            <Button text="打开文件" prefHeight="50" prefWidth="150" onAction="#openImgFile"/>
            <Button text="打开文件夹" prefHeight="50" prefWidth="150" onAction="#openImgFold"/>
        </HBox>
    </GridPane>
    
    <!-- 操作按钮 -->
    <HBox alignment="CENTER" spacing="20" fx:id="optBtns" visible="false">
        <AnchorPane.leftAnchor>0</AnchorPane.leftAnchor>
        <AnchorPane.rightAnchor>0</AnchorPane.rightAnchor>
        <AnchorPane.bottomAnchor>20</AnchorPane.bottomAnchor>
        <Button text="上一张" fx:id="prevBtn" onAction="#goPrev"/>
        <Button text="放大" onAction="#zoomIn"/>
        <Button text="还原" onAction="#rest"/>
        <Button text="缩小" onAction="#zoomOut"/>
        <Button text="旋转" onAction="#rotate"/>
        <Button text="下一张" fx:id="nextBtn" onAction="#goNext"/>
    </HBox>
</AnchorPane>
  1. 外层使用AnchorPane,主要是便于内部节点随窗口变化时能自适应位置。

  2. 操作按钮固定在距离底部20像素位置。

  3. 初始打开时,由于没有图片要展示,所以添加打开文件的按钮占位。点击后可打开图片进行展示,同时隐藏该按钮。后续可通过【打开文件】菜单来重新选择图片。

  4. 打开文件 时可选择多个文件,打开文件夹 会加载文件夹下所有的图片,通过 上/下一页 按钮进行切换。

  5. ImageView 外新增一层节点,一是便于 Label 的居中展示,二是后续的图片拖动主要通过控制这个节点来完成。

1.2 显示主窗口

// ImgViewer.java
public class ImgViewer extends Application {

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

    @Override
    public void start(Stage primaryStage) throws Exception {
        initWindow();
    }

    private static void initWindow() {
        FXMLLoader loader = new FXMLLoader();
        // 加载布局文件
        URL url = Object.class.getResource("/view/ImgViewerView.fxml");
        loader.setLocation(url);
        try {
            Parent layout = loader.load();
            Scene scene = new Scene(layout);
           // 加载样式表
           scene.getStylesheets().add(Objects.requireNonNull(ImgViewer.class.getResource("/com/only/common/css/Only.css")).toString());

            Stage popupStage = new Stage();
            // 设置窗体信息
            popupStage.initModality(Modality.WINDOW_MODAL);
            popupStage.setTitle("图片浏览器");
            popupStage.setScene(scene);
            popupStage.setMinWidth(500);
            popupStage.setMinHeight(400);

            popupStage.setWidth(1000);
            popupStage.setHeight(800);
            
            ImgViewerController controller = loader.getController();
            // 显示窗体
            popupStage.show();
            // 显示图片组件
            controller.showViewer(null, 0, popupStage);

            popupStage.setOnCloseRequest(event -> System.exit(0));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

主入口代码不需要过多介绍,主要是初始化窗体信息。

需要注意的是 showViewer 必须在 popupStage.show() 之后,否则窗体未显示出来,后面的图片初始大小没有进行设置。

2、打开文件/文件夹

2.1 打开文件

// FileChooserUtil.java
public static List<File> getMultiImgFile(String initPath, Stage stage) {
    Scene scene = stage.getScene();
    Window w = (null == scene) ? null : scene.getWindow();
    return getImgFileChooser(initPath).showOpenMultipleDialog(w);
}

private static FileChooser getImgFileChooser(String initPath) {
    FileChooser fileChooser = new FileChooser();
    if (StrKit.notBlank(initPath)) {
        File file = new File(initPath);
        if (file.exists() && !file.isDirectory()) {
            fileChooser.setInitialDirectory(file);
        }
    }

    fileChooser.setTitle("请选择文件");
    fileChooser.getExtensionFilters().addAll(
            new FileChooser.ExtensionFilter("图片文件", "*.png", "*.jpg", "*.jpeg", "*.gif"),
            new FileChooser.ExtensionFilter("PNG", "*.png"),
            new FileChooser.ExtensionFilter("JPG", "*.jpg", "*.jpeg"),
            new FileChooser.ExtensionFilter("GIF", "*.gif")

    );
    return fileChooser;
}

通过 JavaFxFileChooser.showOpenMultipleDialog 方法打开文件选择器,并可进行文件多选操作。同时,通过增加 ExtensionFilter 来进行文件类型过滤。

2.2 打开文件夹

// FileChooserUtil.java
public static List<String> getImgListFromFolder(String initPath, Stage stage) {
    DirectoryChooser directoryChooser = new DirectoryChooser();
    if (StrKit.notBlank(initPath)) {
        File file = new File(initPath);
        if (file.exists() && !file.isDirectory()) {
            directoryChooser.setInitialDirectory(file);
        }
    }
    Scene scene = stage.getScene();
    Window w = (null == scene) ? null : scene.getWindow();
    File file = directoryChooser.showDialog(w);
    List<String> list = null;
    if (file != null) {
        list = Arrays.asList(Objects.requireNonNull(file.list((dir, name) -> {
            String affix = name.substring(name.lastIndexOf(".") + 1);
            return "'png','jpg','jpeg','gif'".contains("'" + affix + "'");
        })));
        list.sort(String::compareTo);
        for (int i = 0; i < list.size(); i++) {
            list.set(i, file.getPath() + File.separator + list.get(i));
        }
    }
    return list;
}

通过 JavaFxDirectoryChooser.showDialog 方法打开文件夹选择器,并将得到的文件列表进行过滤,只保留图片文件。

3、图片展示

// ImgViewerController.java
private void showImg() {
    try {
        if (fileList == null || fileList.isEmpty()) {
            return;
        }
        if (currentIndex < 0) {
            currentIndex = 0;
            return;
        }
        if (currentIndex >= fileList.size()) {
            currentIndex = fileList.size() - 1;
            return;
        }

        prevBtn.setDisable(false);
        nextBtn.setDisable(false);
        if (currentIndex == 0) {
            prevBtn.setDisable(true);
        }
        if (currentIndex == fileList.size() - 1) {
            nextBtn.setDisable(true);
        }

        imageView.setRotate(0);
        imgViewPane.setTranslateX(0);
        imgViewPane.setTranslateY(0);

        this.popupStage.setTitle(PathKit.getFileName(fileList.get(currentIndex)));
        File imgFile = new File(fileList.get(currentIndex));
        if (!imgFile.exists()) {
            imageView.setVisible(false);
            tipsLabel.setText("文件不存在或已损坏");
            tipsLabel.setVisible(true);
            return;
        } else {
            tipsLabel.setVisible(false);
            imageView.setVisible(true);
        }
        Image image = new Image(new FileInputStream(imgFile));
        double imgWidth = image.getWidth();
        double imgHeight = image.getHeight();
        double windWidth = popupStage.getScene().getWidth();
        double windHeight = popupStage.getScene().getHeight();

        double finalWidth = Math.min(imgWidth, windWidth);
        double finalHeight = Math.min(imgHeight, windHeight);

        ImageSize size = ImageUtil.calFinalImgSize(imgWidth, imgHeight, finalWidth, finalHeight);

        imageView.setFitHeight(size.getHeight());
        imageView.setFitWidth(size.getWidth());

        imgViewPane.setPrefWidth(size.getWidth());
        imgViewPane.setPrefHeight(size.getHeight());

        imageView.setImage(image);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }

}

// ImageUtil.java
public static ImageSize calFinalImgSize(double imgWidth, double imgHeight, double containerWidth, double containerHeight) {
    double finalWidth = containerWidth;
    double finalHeight = containerHeight;
    // 宽度过大
    if (imgWidth / imgHeight > containerWidth / containerHeight) {
        finalHeight = containerWidth / imgWidth * imgHeight;
    } else {
        // 高度过大
        finalWidth = containerHeight / imgHeight * imgWidth;
    }
    return new ImageSize(finalWidth, finalHeight);
}
  1. 一些控制

    • 图片列表为空时不展示
    • 图片文件损毁时提示
    • 上/下一页的按钮展示控制
  2. 根据窗口和图片的尺寸大小计算得到最终的图片展示尺寸。

    • 图片高度过长,则最大高度为视窗高度,图片宽度按照高度的缩放比进行缩放。
    • 图片宽度过长,则最大宽度为视窗宽度,图片高度按照宽度的缩放比进行缩放。
  3. 根据最后得到的最终尺寸设置图片和图片外层的节点高度。

  4. ImageView 中设置图片。

至此,图片展示就完成了。

4、操作响应

4.1 拖动

// ImgViewerController.java
new DragListener(imgViewPane, new ImgViewerImgViewPaneDragListenerCall());

// DragListener.java
public class DragListener implements EventHandler<MouseEvent> {
    private Node container = null;
    private double xOffset = 0;
    private double yOffset = 0;
    private Node node;
    private DragListenerCall call;

    public DragListener(Node node, Node container, DragListenerCall call) {
        this.node = node;
        this.node.setOnMousePressed(this);
        this.node.setOnMouseDragged(this);
        this.node.setOnMouseReleased(this);
        this.call = call;
        this.container = container;
    }

    public DragListener(Node node, DragListenerCall call) {
        this.node = node;
        this.node.setOnMousePressed(this);
        this.node.setOnMouseDragged(this);
        this.node.setOnMouseReleased(this);
        this.call = call;
    }

    @Override
    public void handle(MouseEvent event) {
        if (event.getEventType() == MouseEvent.MOUSE_PRESSED) {
            // 记录鼠标点击的初始位置
            xOffset = event.getX();
            yOffset = event.getY();
            if (this.call != null) {
                this.call.onMousePressed(this.node, event);
            }
        } else if (event.getEventType() == MouseEvent.MOUSE_DRAGGED) {
            // 最终偏移量=当前偏移量+鼠标当前位置-鼠标移动开始的位置
            double transX = node.getTranslateX() + event.getX() - xOffset;
            double transY = node.getTranslateY() + event.getY() - yOffset;
            if (this.container != null) {
                Bounds bounds = node.getLayoutBounds();
                Bounds cBonuds = container.getLayoutBounds();
                // 有外部容器,则要保证不能移到外部容器的外面
                if (bounds.getMinX() + transX < cBonuds.getMinX() || bounds.getMaxX() + transX > cBonuds.getMaxX()
                        || bounds.getMaxY() + transY > cBonuds.getMaxY() || bounds.getMinY() + transY < cBonuds.getMinY()) {
                    return;
                }
            }
            node.setTranslateX(transX);
            node.setTranslateY(transY);
            if (this.call != null) {
                this.call.onMouseDragged(this.node, event);
            }
        } else if (event.getEventType() == MouseEvent.MOUSE_RELEASED) {
            if (this.call != null) {
                this.call.onMouseReleased(this.node, event);
            }
        }
    }

}

一个完整的拖动动作主要包含

  • 点击事件(MouseEvent.MOUSE_PRESSED)
  • 拖动事件(MouseEvent.MOUSE_DRAGGED)
  • 释放事件(MouseEvent.MOUSE_RELEASED)

点击时,记录当前的点击位置,拖动完,设置控件的偏移位置。 最终偏移量=当前偏移量+鼠标当前位置鼠标移动开始的位置最终偏移量=当前偏移量+鼠标当前位置-鼠标移动开始的位置

4.2 缩放

public void zoom(double factor) {
    imageView.setFitWidth(imageView.getFitWidth() * factor);
    imageView.setFitHeight(imageView.getFitHeight() * factor);
    imgViewPane.setPrefWidth(imgViewPane.getPrefWidth() * factor);
    imgViewPane.setPrefHeight(imgViewPane.getPrefHeight() * factor);
}

// 放大
public void zoomIn() {
    zoom(1.2);
}

// 缩小
public void zoomOut() {
    zoom(1 / 1.2);
}

// 鼠标滚轮缩放
imgViewPane.setOnScroll(event -> {
    double deltaY = event.getDeltaY();
    if (deltaY < 0) {
        zoomOut();
    } else {
        zoomIn();
    }
    event.consume();
});

// 触摸板缩放
imgViewPane.setOnZoom(event -> {
    zoom(event.getZoomFactor());
    event.consume();
});

设置宽高按比例缩放即可。同时给按钮、鼠标滚轮、触控板绑定相关事件。

4.3 旋转

public void rotate() {
    rotate += 90;
    imageView.setRotate(rotate % 360);
}

使用控件的setRotate方法即可。360为一圈。

4.4 还原

public void rest() {
    // 还原为和窗口一样
    showImg();
    
    rotate = 0;
    imageView.setRotate(rotate);
    imgViewPane.setTranslateX(0);
    imgViewPane.setTranslateY(0);
}

重新展示图片(重新计算宽高,缩放还原),旋转角度设置为0,拖动还原。

4.5 上/下一页

@FXML
public void goPrev() {
    --currentIndex;
    showImg();
}

/**
 * 下一张
 */
@FXML
public void goNext() {
    ++currentIndex;
    showImg();
}

只需设置当前文件的索引即可。

5、总结

这是个练手项目,理清思路后,整体功能比较实现比较简单。可以通过此项目了解javafx的布局和基础控件的使用,以及事件交互。

源码地址:gitee.com/newey/java-…