JavaFX17 学习手册(十一)
二十一、了解图像 API
在本章中,您将学习:
-
什么是图像 API
-
如何加载图像
-
如何查看
ImageView节点中的图像 -
如何执行图像操作,例如读取/写入像素、从头开始创建图像以及将图像保存到文件系统
-
如何拍摄节点和场景的快照
本章的例子在com.jdojo.image包中。为了让它们工作,您必须在module-info.java文件中添加相应的一行:
...
opens com.jdojo.image to javafx.graphics, javafx.base;
...
什么是图像 API?
JavaFX 提供了 Image API,允许您加载和显示图像,以及读/写原始图像像素。图像 API 中类的类图如图 21-1 所示。所有的类都在javafx.scene.image包中。该 API 允许您
图 21-1
图像 API 中的类的类图
-
将图像加载到内存中
-
将图像显示为场景图中的节点
-
从图像中读取像素
-
将像素写入图像
-
将场景图形中的节点转换为图像,并将其保存到本地文件系统
Image类的一个实例表示内存中的一幅图像。通过向一个WritableImage实例提供像素,可以在 JavaFX 应用程序中构造一个图像。
一个ImageView就是一个Node。它用于在场景图中显示一个Image。如果您想在应用程序中显示图像,您需要在Image中加载图像,并在ImageView中显示图像。
图像由像素构成。图像中像素的数据可以以不同的格式存储。PixelFormat定义如何存储给定格式的像素数据。WritablePixelFormat表示用全像素颜色信息写入像素的目的格式。
PixelReader和PixelWriter接口定义了从Image读取数据和向WritableImage写入数据的方法。除了一个Image之外,你可以从任何包含像素的表面读取像素,也可以向任何包含像素的表面写入像素。
我将在接下来的章节中介绍使用这些类的例子。
加载图像
Image类的一个实例是一个图像的内存表示。该类支持 BMP、PNG、JPEG 和 GIF 图像格式。它从一个源加载一个图像,这个源可以被指定为一个字符串 URL 或者一个InputStream。它还可以在加载时缩放原始图像。
Image类包含几个构造器,允许您为加载的图像指定属性:
-
Image(InputStream is) -
Image(InputStream is, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) -
Image(String url) -
Image(String url, boolean backgroundLoading) -
Image(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) -
Image(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth, boolean backgroundLoading)
如果将InputStream指定为来源,则图像的来源没有歧义。如果将字符串 URL 指定为源,它可能是有效的 URL 或类路径中的有效路径。如果指定的 URL 不是有效的 URL,它将被用作路径,并且将在CLASSPATH中的路径上搜索图像源:
// Load an image from local machine using an InputStream
String sourcePath = "C:\\mypicture.png";
Image img = new Image(new FileInputStream(sourcePath));
// Load an image from an URL
Image img = new Image("http://jdojo.com/wp-content/uploads/2013/03/randomness.jpg");
// Load an image from the CLASSPATH. The image is located in the resources.picture package
Image img = new Image("resources/picture/randomness.jpg");
在前面的语句中,指定的 URL resources/picture/randomness.jpg不是有效的 URL。Image类将把它视为一条路径,期望它存在于CLASSPATH中。它将resource.picture视为一个包,将randomness.jpg视为该包中的一个资源。
Tip
如果您想测试本章中的代码片段,请确保添加有效的 URL。要么确保使用相对 URL,比如在CLASSPATH中的resources/picture/randomness.jpg,要么指定绝对 URL,比如http://path/to/my/server/resources/picture/randomness.jpg或file://some/absolute/path/resources/picture/randomness.jpg。
指定图像加载属性
有些构造器允许您指定一些图像加载属性来控制图像质量和加载过程:
-
requestedWidth -
requestedHeight -
preserveRatio -
smooth -
backgroundLoading
requestedWidth和requestedHeight属性指定图像的缩放宽度和高度。默认情况下,图像以其原始大小加载。
The preserveRatio属性指定缩放时是否保留图像的纵横比。默认情况下,它是假的。
smooth属性指定在缩放中使用的过滤算法的质量。默认情况下,它是假的。如果设置为 true,将使用质量更好的过滤算法,这会稍微减慢图像加载过程。
属性指定是否异步加载图像。默认情况下,该属性设置为 false,并且同步加载图像。当Image对象被创建时,加载过程开始。如果此属性设置为 true,图像将在后台线程中异步加载。
读取加载的图像属性
Image类包含以下只读属性:
-
width -
height -
progress -
error -
exception
width和height属性分别是加载图像的宽度和高度。如果图像加载失败,则它们为零。
progress属性表示加载图像数据的进度。当backgroundLoading属性设置为 true 时,了解进度是很有用的。其值介于 0.0 和 1.0 之间,其中 0.0 表示 0%负载,1.0 表示 100%负载。当backgroundLoading属性设置为 false(默认值)时,其值为 1.0。您可以在progress属性中添加一个ChangeListener来了解图像加载的进度。您可以在图像加载时将文本显示为图像的占位符,并在ChangeListener中用当前进度更新文本:
// Load an image in the background
String imagePath = "resources/picture/randomness.jpg";
Boolean backgroundLoading = true;
Image image = new Image(imagePath, backgroundLoading);
// Print the loading progress on the standard output
image.progressProperty().addListener((prop, oldValue, newValue) -> {
System.out.println("Loading:" +
Math.round(newValue.doubleValue() * 100.0) + "%");
});
error属性指示加载图像时是否出现错误。如果为真,exception属性指定了导致错误的Exception。在撰写本文时,Windows 不支持 TIFF 图像格式。以下代码片段试图在 Windows XP 上加载 TIFF 图像,并产生错误。该代码包含一个错误处理逻辑,如果backgroundLoading为真,则向error属性添加一个ChangeListener。否则,它检查error属性的值:
String imagePath = "resources/picture/test.tif";
Boolean backgroundLoading = false;
Image image = new Image(imagePath, backgroundLoading);
// Add a ChangeListener to the error property for background loading and
// check its value for non-background loading
if (image.isBackgroundLoading()) {
image.errorProperty().addListener((prop, oldValue, newValue) -> {
if (newValue) {
System.out.println(
"An error occurred while loading the image.\n" +
"Error message: " +
image.getException().getMessage());
}
});
}
else if (image.isError()) {
System.out.println("An error occurred while loading the image.\n" +
"Error message: " +
image.getException().getMessage());
}
An error occurred while loading the image.
Error message: No loader for image data
查看图像
ImageView类的一个实例用于显示加载到Image对象中的图像。ImageView类继承自Node类,这使得ImageView适合添加到场景图形中。该类包含几个构造器:
-
ImageView() -
ImageView(Image image) -
ImageView(String url)
无参数构造器创建一个没有图像的ImageView。使用image属性来设置图像。第二个构造器接受一个Image的引用。第三个构造器让您指定图像源的 URL。在内部,它使用指定的 URL 创建一个Image:
// Create an empty ImageView and set an Image for it later
ImageView imageView = new ImageView();
imageView.setImage(new Image("resources/picture/randomness.jpg"));
// Create an ImageView with an Image
ImageView imageView = new ImageView(new Image("resources/picture/randomness.jpg"));
// Create an ImageView with the URL of the image source
ImageView imageView = new ImageView("resources/picture/randomness.jpg");
清单 21-1 中的程序展示了如何在场景中显示图像。它将图像加载到一个Image对象中。在不保留纵横比的情况下缩放图像。Image对象被添加到一个ImageView,后者被添加到一个HBox。图 21-2 为窗口。
图 21-2
带有图像的窗口
// ImageTest.java
package com.jdojo.image;
import com.jdojo.util.ResourceUtil;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;
public class ImageTest extends Application {
public static void main(String[] args) {
Application.launch(args);
}
@Override
public void start(Stage stage) {
String imagePath =
ResourceUtil.getResourceURLStr("picture/randomness.jpg");
// Scale the image to 200 X 100
double requestedWidth = 200;
double requestedHeight = 100;
boolean preserveRatio = false;
boolean smooth = true;
Image image = new Image(imagePath,
requestedWidth,
requestedHeight,
preserveRatio,
smooth);
ImageView imageView = new ImageView(image);
HBox root = new HBox(imageView);
Scene scene = new Scene(root);
stage.setScene(scene);
stage.setTitle("Displaying an Image");
stage.show();
}
}
Listing 21-1Displaying an Image in an ImageView Node
图像的多个视图
一个Image从其来源将图像加载到内存中。同一个Image可以有多个视图。一位ImageView提供了其中一种观点。
您可以选择在加载和/或显示时调整原始图像的大小。选择哪个选项来调整图像的大小取决于手头的要求:
-
在一个
Image对象中调整图像的大小会在内存中永久地调整图像的大小,并且图像的所有视图都将使用调整后的图像。一旦调整了Image的大小,它的大小就不能改变了。您可能希望缩小Image对象中的图像尺寸以节省内存。 -
在
ImageView中调整图像的大小只会为该视图调整image的大小。即使图像已经显示,您也可以在ImageView中调整图像视图的大小。
我们已经讨论过如何在一个Image对象中调整图像的大小。在这一节中,我们将讨论在ImageView中调整图像的大小。
类似于Image类,ImageView类包含以下四个属性来控制图像视图的大小调整:
-
fitWidth -
fitHeight -
preserveRatio -
smooth
fitWidth和fitHeight属性分别指定调整后的图像的宽度和高度。默认情况下,它们是零,这意味着ImageView将使用Image中加载图像的宽度和高度。
属性指定在调整大小时是否保持图像的纵横比。默认情况下,它是假的。
属性指定在调整大小时使用的过滤算法的质量。其默认值取决于平台。如果设置为 true,则使用质量更好的过滤算法。
清单 21-2 中的程序以原始尺寸在Image对象中加载图像。它创建了指定不同大小的Image的三个ImageView对象。图 21-3 显示了三幅图像。图片显示的是一辆垃圾校车和一辆垃圾汽车。该图像经理查德·卡斯蒂略( www.digitizedchaos.com )许可使用。
图 21-3
同一图像的三视图
// MultipleImageViews.java
// ...find in the book's download area.
Listing 21-2Displaying the Same Image in Different ImageView in Different Sizes
在视口中查看图像
视口是一个矩形区域,用于查看图形的一部分。滚动条通常与视口一起使用。当滚动条滚动时,视口显示图形的不同部分。
ImageView可让您定义图像的视窗。在 JavaFX 中,视口是javafx.geometry.Rectangle2D对象的一个实例。Rectangle2D是不可改变的。它由四个属性定义:minX、minY、width和height。(minX,minY)值定义矩形左上角的位置。宽度和高度属性指定其大小。您必须在构造器中指定所有属性:
// Create a viewport located at (0, 0) and of size 200 X 100
Rectangle2D viewport = new Rectangle2D(0, 0, 200,100);
ImageView类包含一个viewport属性,它提供了一个进入显示在ImageView中的图像的视窗。viewport定义了图像中的一个矩形区域。ImageView只显示图像中落在视窗内的区域。视窗的位置是相对于图像定义的,而不是ImageView。默认情况下,ImageView的视窗为空,ImageView显示整个图像。
下面的代码片段在Image中加载原始大小的图像。Image被设置为ImageView的源。为ImageView设置尺寸为 200 X 100 的视窗。视口位于(0,0)处。这显示在ImageView图像的左上角 200 X 100 的区域:
String imagePath = "resources/picture/school_bus.jpg";
Image image = new Image(imagePath);
imageView = new ImageView(image);
Rectangle2D viewport = new Rectangle2D(0, 0, 200, 100);
imageView.setViewport(viewport);
以下代码片段将更改视区以显示图像的 200 X 100 右下角区域:
double minX = image.getWidth() - 200;
double minY = image.getHeight() - 100;
Rectangle2D viewport2 = new Rectangle2D(minX, minY, 200, 100);
imageView.setViewport(viewport2);
Tip
Rectangle2D类是不可变的。因此,每次想要将视口移动到图像中时,都需要创建一个新的视口。
清单 21-3 中的程序将图像加载到ImageView中。它为ImageView设置一个视口。您可以拖动鼠标,同时按下左、右或两个按钮,滚动到视图中图像的不同部分。
// ImageViewPort.java
// ...find in the book's download area.
Listing 21-3Using a Viewport to View Part of an Image
程序声明了一些类和实例变量。VIEWPORT_WIDTH和VIEWPORT_HEIGHT是保存视口宽度和高度的常量。当鼠标被按下或拖动时,startX和startY实例变量将保存鼠标的 x 和 y 坐标。ImageView实例变量保存了ImageView的引用。在鼠标拖动的事件处理程序中,我们需要这个引用。
start()方法的开始部分很简单。它创建一个Image,一个ImageView,并为ImageView设置一个视口。然后,它将按下鼠标和拖动鼠标的事件处理程序设置为ImageView:
// Set the mouse pressed and mouse dragged event handlers
imageView.setOnMousePressed(this::handleMousePressed);
imageView.setOnMouseDragged(this::handleMouseDragged);
在handleMousePressed()方法中,我们将鼠标的坐标存储在startX和startY实例变量中。坐标相对于ImageView:
startX = e.getX();
startY = e.getY();
由于鼠标拖动,handleMousePressed()方法计算图像内视窗的新位置,并在新位置设置一个新视窗。首先,它计算鼠标沿 x 轴和 y 轴的拖动距离:
// How far the mouse was dragged
double draggedDistanceX = e.getX() - startX;
double draggedDistanceY = e.getY() - startY;
您将startX和startY值重置为触发当前鼠标拖动事件的鼠标位置。这对于在用户按住鼠标、拖动鼠标、停止而不松开鼠标,然后再次拖动鼠标时获得正确的拖动距离非常重要:
// Reset the starting point for the next drag
// if the user keeps the mouse pressed and drags again
startX = e.getX();
startY = e.getY();
计算视口左上角的新位置。在ImageView中你总是有一个视窗。新视口将位于旧位置的拖动距离处:
// Get the minX and minY of the current viewport
double curMinX = imageView.getViewport().getMinX();
double curMinY = imageView.getViewport().getMinY();
// Move the new viewport by the dragged distance
double newMinX = curMinX + draggedDistanceX;
double newMinY = curMinY + draggedDistanceY;
将视口放在图像区域之外是可以的。当视窗落在图像区域之外时,它只显示一个空白区域。为了将视口限制在图像区域内,我们夹紧视口的位置:
// Make sure the viewport does not fall outside the image area
newMinX = clamp(newMinX, 0, imageView.getImage().getWidth() - VIEWPORT_WIDTH);
newMinY = clamp(newMinY, 0, imageView.getImage().getHeight() - VIEWPORT_HEIGHT);
最后,我们使用新位置设置一个新的视口:
// Set a new viewport
imageView.setViewport(new Rectangle2D(newMinX, newMinY, VIEWPORT_WIDTH, VIEWPORT_HEIGHT));
Tip
可以缩放或旋转ImageView并设置一个视窗来查看由视窗定义的图像区域。
了解图像操作
JavaFX 支持从图像中读取像素、向图像中写入像素以及创建场景的快照。它支持从头开始创建图像。如果图像是可写的,您还可以修改内存中的图像,并将其保存到文件系统中。图像 API 提供了对图像中每个像素的访问。它支持一次读写一个像素或一大块像素。本节将通过简单的例子讨论 Image API 支持的操作。
像素格式
JavaFX 中的 Image API 允许您访问图像中的每个像素。像素存储有关其颜色(红色、绿色、蓝色)和不透明度(alpha)的信息。像素信息可以以几种格式存储。
一个实例PixelFormat<T extends Buffer>代表一个像素的数据布局。当你从图像中读取像素时,你需要知道像素格式。将像素写入图像时,需要指定像素格式。WritablePixelFormat类继承自PixelFormat类,它的实例代表一种可以存储全彩色信息的像素格式。当向图像写入像素时,使用WritablePixelFormat类的一个实例。
类PixelFormat和它的子类WritablePixelFormat都是抽象的。PixelFormat类提供了几个静态方法来获取PixelFormat和WritablePixelFormat抽象类的实例。在我们讨论如何获得一个PixelFormat的实例之前,让我们讨论一下可用于存储像素数据的存储格式的类型。
一个PixelFormat有一个指定单个像素的存储格式的类型。PixelFormat.Type枚举的常量代表不同类型的存储格式:
-
BYTE_RGB -
BYTE_BGRA -
BYTE_BGRA_PRE -
BYTE_INDEXED -
INT_ARGB -
INT_ARGB_PRE
在BYTE_RGB格式中,像素被认为是不透明的。像素按顺序以红色、绿色和蓝色存储在相邻的字节中。
在BYTE_BGRA格式中,像素按照蓝色、绿色、红色和 alpha 顺序存储在相邻的字节中。颜色值(红色、绿色和蓝色)不会与 alpha 值预先相乘。
BYTE_BGRA_PRE类型格式类似于BYTE_BGRA,除了在BYTE_BGRA_PRE中,存储的颜色分量值预先乘以阿尔法值。
在BYTE_INDEXED格式中,一个像素是一个字节。提供了单独的颜色查找列表。像素的单字节值用作查找列表中的索引,以获取像素的颜色值。
在INT_ARGB格式中,每个像素以 32 位整数存储。从最高有效字节(MSB)到最低有效字节(LSB)的字节存储 alpha、红色、绿色和蓝色值。颜色值(红色、绿色和蓝色)不会与 alpha 值预先相乘。以下代码片段显示了如何以这种格式从像素值中提取分量:
int pixelValue = get the value for a pixel...
int alpha = (pixelValue >> 24) & 0xff;
int red = (pixelValue >> 16) & 0xff;
int green = (pixelValue >> 8) & 0xff;
int blue = pixelValue & 0xff;
除了INT_ARGB_PRE存储预先乘以 alpha 值的颜色值(红色、绿色和蓝色)之外,INT_ARGB_PRE格式类似于INT_ARGB格式。
通常,当你写像素来创建一个新的图像时,你需要创建一个WritablePixelFormat。当您从图像中读取像素时,像素读取器将为您提供一个PixelFormat实例,告诉您像素中的颜色信息是如何存储的。下面的代码片段创建了WritablePixelFormat类的一些实例:
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritablePixelFormat;
import java.nio.ByteBuffer;
import java.nio.IntBuffer;
...
// BYTE_BGRA Format type
WritablePixelFormat<ByteBuffer> format1 = PixelFormat.getByteBgraInstance();
// BYTE_BGRA_PRE Format type
WritablePixelFormat<ByteBuffer> format2 =
PixelFormat.getByteBgraPreInstance();
// INT_ARGB Format type
WritablePixelFormat<IntBuffer> format3 = PixelFormat.getIntArgbInstance();
// INT_ARGB_PRE Format type
WritablePixelFormat<IntBuffer> format4 = PixelFormat.getIntArgbPreInstance();
没有像素信息,像素格式类是没有用的。毕竟它们描述的是信息在一个像素中的布局!在接下来的章节中,当我们读写图像像素时,我们将使用这些类。它们的使用在例子中是显而易见的。
从图像中读取像素
接口的一个实例用于从图像中读取像素。使用Image类的getPixelReader()方法获得一个PixelReader。PixelReader接口包含以下方法:
-
int getArgb(int x, int y) -
Color getColor(int x, int y) -
Void getPixels(int x, int y, int w, int h, WritablePixelFormat<ByteBuffer> pixelformat, byte[] buffer, int offset, int scanlineStride) -
void getPixels(int x, int y, int w, int h, WritablePixelFormat<IntBuffer> pixelformat, int[] buffer, int offset, int scanlineStride) -
<T extends Buffer> void getPixels(int x, int y, int w, int h, WritablePixelFormat<T> pixelformat, T buffer, int scanlineStride) -
PixelFormat getPixelFormat()
PixelReader接口包含一次读取一个或多个像素的方法。使用getArgb()和getColor()方法读取指定(x,y)坐标的像素。使用getPixels()方法批量读取像素。使用getPixelFormat()方法获得最能描述源中像素存储格式的PixelFormat。
只有当图像可读时,Image类的getPixelReader()方法才返回一个PixelReader。否则返回null。如果图像尚未完全加载、加载过程中出现错误或其格式不支持读取像素,则图像可能不可读:
Image image = new Image("file://.../resources/picture/ksharan.jpg");
// Get the pixel reader
PixelReader pixelReader = image.getPixelReader();
if (pixelReader == null) {
System.out.println("Cannot read pixels from the image");
} else {
// Read image pixels
}
一旦有了一个PixelReader,就可以调用它的一个方法来读取像素。清单 21-4 中的程序展示了如何从图像中读取像素。代码是不言自明的:
-
start()方法创建一个Image。Image同步加载。 -
读取像素的逻辑在
readPixelsInfo()方法中。该方法接收一个完全加载的Image。它使用PixelReader的getColor()方法获取指定位置的像素。它打印所有像素的颜色。最后,它打印像素格式,这是BYTE_RGB。
// ReadPixelInfo.java
// ...find in the book's download area.
Color at (0, 0) = 0xb5bb41ff
Color at (1, 0) = 0xb0b53dff
...
Color at (233, 287) = 0x718806ff
Color at (234, 287) = 0x798e0bff
Pixel format type: BYTE_RGB
Listing 21-4Reading Pixels from an Image
批量读取像素比一次读取一个像素要困难一些。困难来自于您必须提供给getPixels()方法的设置信息。我们将通过使用PixelReader的以下方法批量读取所有像素来重复前面的示例:
void getPixels(int x, int y,
int width, int height,
WritablePixelFormat<ByteBuffer> pixelformat,
byte[] buffer,
int offset,
int scanlineStride)
该方法按顺序从行中读取像素。读取第一行的像素,然后读取第二行的像素,依此类推。理解该方法所有参数的含义非常重要。
方法读取源中矩形区域的像素。
矩形区域左上角的 x 和 y 坐标在x and y参数中指定。
width和height参数指定矩形区域的宽度和高度。
pixelformat指定了用于在指定的buffer中存储读取像素的像素格式。
buffer是一个byte数组,其中PixelReader将存储读取的像素。数组的长度必须足够大,以存储所有读取的像素。
offset指定了buffer数组中存储第一个像素数据的起始索引。其零值表示第一个像素的数据将从缓冲区中的索引 0 开始。
scanlineStride指定缓冲区中一行数据的起点和下一行数据的起点之间的距离。假设你在一行中有两个像素,你想以一个像素 4 个字节的BYTE_BGRA格式读取。一行数据可以存储在 8 个字节中。如果将参数值指定为 8,则下一行的数据将在前一行数据结束后立即在缓冲区中开始。如果将参数值指定为 10,则每行数据的最后 2 个字节将为空。第一行像素将从索引 0 到 7 存储。索引 8 和 9 将为空(或未被写入)。索引 10 至 17 将存储第二行的像素数据,索引 18 和 19 为空。如果以后要用自己的值填充空槽,可能需要为参数指定一个比存储一行像素数据所需的值更大的值。指定一个小于所需的值将会覆盖前一行中的部分数据。
以下代码片段显示了如何以BYTE_BGRA格式从一个byte数组中读取图像的所有像素:
Image image = ...
PixelReader pixelReader = image.getPixelReader();
int x = 0;
int y = 0;
int width = (int)image.getWidth();
int height = (int)image.getHeight();
int offset = 0;
int scanlineStride = width * 4;
byte[] buffer = new byte[width * height * 4];
// Get a WritablePixelFormat for the BYTE_BGRA format type
WritablePixelFormat<ByteBuffer> pixelFormat = PixelFormat.getByteBgraInstance();
// Read all pixels at once
pixelReader.getPixels(x, y,
width, height,
pixelFormat,
buffer,
offset,
scanlineStride);
要读取的矩形区域的左上角的 x 和 y 坐标被设置为零。区域的宽度和高度被设置为图像的宽度和高度。这将设置参数来读取整个图像。
您希望从索引 0 开始将像素数据读入缓冲区,因此将offset参数设置为 0。
你想读取BYTE_BGRA格式类型的像素数据,需要 4 个字节来存储一个像素的数据。我们已经将参数值scanlineStride设置为width * 4,它是一行数据的长度,因此一行数据从上一行数据结束的下一个索引开始。
您获得了一个WritablePixelFormat的实例来读取BYTE_BGRA格式类型的数据。最后,我们调用PixelReader的getPixels()方法来读取像素数据。当getPixels()方法返回时,buffer将被像素数据填充。
Tip
设置scanlineStride参数的值和缓冲区数组的长度取决于pixelFormat参数。其他版本的getPixels()方法允许读取不同格式的像素数据。
清单 21-5 中的程序有完整的源代码来批量读取像素。读取所有像素后,它对(0,0)处像素的字节数组中的颜色分量进行解码。它使用getColor()方法读取(0,0)处的像素。通过两种方法获得的(0,0)处的像素数据打印在标准输出上。
// BulkPixelReading.java
// ...find in the book's download area.
red=181, green=187, blue=65, alpha=255
red=181, green=187, blue=65, alpha=255
Listing 21-5Reading Pixels from an Image in Bulk
将像素写入图像
您可以将像素写入图像或任何支持写入像素的表面。例如,您可以将像素写入一个WritableImage和一个Canvas。
Tip
一个Image是一个只读像素表面。您可以从Image中读取像素。但是,您不能将像素写入Image。如果您想写入图像或从头开始创建图像,请使用WritableImage。
接口的一个实例被用来将像素写到一个表面上。可写表面提供了一个PixelWriter。例如,您可以使用Canvas和WritableImage的getPixelWriter()方法为它们获取一个PixelWriter。
PixelWriter接口包含将像素写入表面并获得表面支持的像素格式的方法:
-
PixelFormat getPixelFormat() -
void setArgb(int x, int y, int argb) -
void setColor(int x, int y, Color c) -
void setPixels(int x, int y, int w, int h, PixelFormat<ByteBuffer> pixelformat, byte[] buffer, int offset, int scanlineStride) -
void setPixels(int x, int y, int w, int h, PixelFormat<IntBuffer> pixelformat, int[] buffer, int offset, int scanlineStride) -
<T extends Buffer> void setPixels(int x, int y, int w, int h, PixelFormat<T> pixelformat, T buffer, int scanlineStride) -
void setPixels(int dstx, int dsty, int w, int h, PixelReader reader, int srcx, int srcy)
getPixelFormat()方法返回像素可以写入表面的像素格式。setArgb()和setColor()方法允许在目标表面的指定(x,y)位置写入一个像素。setArgb()方法接受 INT_ARGB 格式的整数像素数据,而setColor()方法接受颜色对象。setPixels()方法允许批量像素写入。
您可以使用WritableImage的实例从头开始创建图像。该类包含三个构造器:
-
WritableImage(int width, int height) -
WritableImage(PixelReader reader, int width, int height) -
WritableImage(PixelReader reader, int x, int y, int width, int height)
第一个构造器创建一个指定的width和height的空图像:
// Create a new empty image of 200 X 100
WritableImage newImage = new WritableImage(200, 100);
第二个构造器创建指定的width和height的图像。指定的reader用于用像素填充图像。如果阅读器从一个没有足够的行数和列数来填充新图像的表面读取,就会抛出一个ArrayIndexOutOfBoundsException。使用此构造器复制整个或部分图像。以下代码片段创建了一个图像的副本:
String imagePath = "file://.../resources/picture/ksharan.jpg";
Image image = new Image(imagePath, 200, 100, true, true);
int width = (int)image.getWidth();
int height = (int)image.getHeight();
// Create a copy of the image
WritableImage newImage =
new WritableImage(image.getPixelReader(), width, height);
第三个构造器允许您从表面复制一个矩形区域。(x,y)值是矩形区域左上角的坐标。(width、height)值是使用reader读取的矩形区域的尺寸和新图像的所需尺寸。如果阅读器从一个没有足够的行数和列数来填充新图像的表面读取,就会抛出一个ArrayIndexOutOfBoundsException。
WritableImage是一个读写映像。它的getPixelWriter()方法返回一个PixelWriter来将像素写入图像。它继承了返回一个从图像中读取数据的PixelReader的getPixelReader()方法。
下面的代码片段创建了一个Image和一个空的WritableImage。它从Image中一次读取一个像素,使像素变暗,并将相同的像素写入新的WritableImage。最后,我们创建了原始图像的一个更暗的副本:
Image image = new Image("file://.../resources/picture/ksharan.jpg";);
PixelReader pixelReader = image.getPixelReader();
int width = (int)image.getWidth();
int height = (int)image.getHeight();
// Create a new, empty WritableImage
WritableImage darkerImage = new WritableImage(width, height);
PixelWriter darkerWriter = darkerImage.getPixelWriter();
// Read one pixel at a time from the source and
// write it to the destinations - one darker and one brighter
for(int y = 0; y < height; y++) {
for(int x = 0; x < width; x++) {
// Read the pixel from the source image
Color color = pixelReader.getColor(x, y);
// Write a darker pixel to the new image at the same
// location
darkerWriter.setColor(x, y, color.darker());
}
}
清单 21-6 中的程序创建一个Image。它创建了三个WritableImage实例,并将原始图像中的像素复制到其中。复制的像素在写入目标之前会被修改。对于一个目的地,像素变暗,一个变亮,一个变成半透明。四幅图像都显示在ImageViews中,如图 21-4 所示。
图 21-4
原始图像和修改后的图像
// CopyingImage.java
// ...find in the book's download area.
Listing 21-6Writing Pixels to an Image
Tip
在 JavaFX 中裁剪图像很容易。使用PixelReader的getPixels()方法之一读取缓冲区中所需的图像区域,并将缓冲区写入新图像。这为您提供了一个新图像,它是原始图像的裁剪版本。
从头开始创建图像
在上一节中,我们通过从另一个图像复制像素来创建新图像。在将原始像素写入新图像之前,我们已经改变了它们的颜色和不透明度。这很简单,因为我们一次处理一个像素,我们接收一个像素作为Color对象。也可以从头开始创建像素,然后使用它们来创建新的图像。任何人都会承认,通过在代码中定义每个像素来创建一个新的、有意义的图像并不是一件容易的事情。然而,JavaFX 使这个过程变得很容易。
在这一节中,我们将创建一个新的图像,它由矩形组成,以类似网格的方式放置。使用连接左上角和右下角的对角线将每个矩形分成两部分。上面的三角形是绿色的,下面的是红色的。将创建一个新图像并用矩形填充。
从头开始创建映像包括三个步骤:
-
创建一个
WritableImage的实例。 -
创建缓冲区(一个
byte数组,一个int数组,等等)。)并根据您希望用于像素数据的像素格式用像素数据填充它。 -
将缓冲区中的像素写入图像。
让我们编写为矩形区域创建像素的代码。让我们为矩形的宽度和高度声明常量:
static final int RECT_WIDTH = 20;
static final int RECT_HEIGHT = 20;
我们需要定义一个足够大的缓冲区(一个byte数组)来保存所有像素的数据。BYTE_RGB格式的每个像素占用 2 个字节:
byte[] pixels = new byte[RECT_WIDTH * RECT_HEIGHT * 3];
如果该区域是矩形的,我们需要知道高度与宽度的比率,以便将该区域分成上下两个矩形:
double ratio = 1.0 * RECT_HEIGHT/RECT_WIDTH;
以下代码片段填充了缓冲区:
// Generate pixel data
for (int y = 0; y < RECT_HEIGHT; y++) {
for (int x = 0; x < RECT_WIDTH; x++) {
int i = y * RECT_WIDTH * 3 + x * 3;
if (x <= y/ratio) {
// Lower-half
pixels[i] = -1; // red -1 means 255 (-1 & 0xff = 255)
pixels[i+1] = 0; // green = 0
pixels[i+2] = 0; // blue = 0
} else {
// Upper-half
pixels[i] = 0; // red = 0
pixels[i+1] = -1; // Green 255
pixels[i+2] = 0; // blue = 0
}
}
}
像素以行优先的顺序存储在缓冲区中。循环中的变量i计算一个像素的 3 字节数据在缓冲区中的起始位置。例如,(0,0)处的像素的数据从索引 0 开始;(0,1)处的像素的数据从索引 3 开始;等等。像素的 3 个字节按照索引递增的顺序存储红色、绿色和蓝色值。颜色分量的编码值存储在缓冲区中,因此表达式“byteValue & 0xff”将产生 0 到 255 之间的实际颜色分量值。如果你想要一个红色像素,你需要为红色分量设置–1,因为“-1 & 0xff产生 255。对于红色,绿色和蓝色分量将被设置为零。字节数组将所有元素初始化为零。然而,我们已经在代码中明确地将它们设置为零。对于下半部分的三角形,我们将颜色设置为绿色。条件“x =<= y/ratio”用于确定一个像素的位置是落在上半三角形还是下半三角形。如果y/ratio不是一个整数,矩形分成两个三角形在右下角可能会有点偏离。
一旦我们获得了像素数据,我们需要将它们写入一个WritableImage。以下代码片段写入矩形的像素,一次在图像的左上角:
WritableImage newImage = new WritableImage(350, 100);
PixelWriter pixelWriter = newImage.getPixelWriter();
byte[] pixels = generate pixel data...
// Our data is in BYTE_RGB format
PixelFormat<ByteBuffer> pixelFormat = PixelFormat.getByteRgbInstance();
Int xPos 0;
int yPos =0;
int offset = 0;
int scanlineStride = RECT_WIDTH * 3;
pixelWriter.setPixels(xPos, yPos,
RECT_WIDTH, RECT_HEIGHT,
pixelFormat,
pixels, offset,
scanlineStride);
清单 21-7 中的程序从头开始创建一个图像。它通过为矩形区域写入行像素来填充图像,从而创建图案。图 21-5 为图示。
图 21-5
从零开始创造的图像
// CreatingImage.java
// ...find in the book's download area.
Listing 21-7Creating an Image from Scratch
将新图像保存到文件系统
将Image保存到文件系统很容易:
-
使用
SwingFXUtils类的fromFXImage()方法将Image转换为BufferedImage。 -
将
BufferedImage传递给ImageIO类的write()方法。
请注意,我们必须使用两个类— BufferedImage和 ImageIO—它们是标准 Java 库的一部分,而不是 JavaFX 库的一部分。以下代码片段显示了将图像保存到 PNG 格式的文件中所涉及的步骤概要:
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import javax.imageio.ImageIO;
...
Image image = create an image...
BufferedImage bImage = SwingFXUtils.fromFXImage(image, null);
// Save the image to the file
File fileToSave = ...
String imageFormat = "png";
try {
ImageIO.write(bImage, imageFormat, fileToSave);
}
catch (IOException e) {
throw new RuntimeException(e);
}
清单 21-8 中的程序有一个实用程序类ImageUtil的代码。它的静态saveToFile(Image image)方法可以用来将一个Image保存到本地文件系统。该方法要求输入文件名。用户可以为图像选择 PNG 或 JPEG 格式。
// ImageUtil.java
// ...find in the book's download area.
Listing 21-8A Utility Class to Save an Image to a File
清单 21-9 中的程序展示了如何将图像保存到文件中。点击Save Image按钮将图片保存到文件中。它会打开一个文件选择器对话框,让您选择文件名。如果取消文件选择器对话框,保存过程将中止。
// SaveImage.java
// ...find in the book's download area.
Listing 21-9Saving an Image to a File
拍摄节点和场景的快照
JavaFX 允许您拍摄下一帧中出现的Node和Scene的快照。您在WritableImage中获取快照,这意味着您可以在获取快照后执行所有像素级操作。Node和Scene类包含一个snapshot()方法来完成这个任务。
拍摄节点的快照
Node类包含一个重载的snapshot()方法:
-
WritableImage snapshot(SnapshotParameters params, WritableImage image) -
void snapshot(Callback<SnapshotResult,Void> callback, SnapshotParameters params, WritableImage image)
第一个版本的snapshot()方法是同步的,而第二个是异步的。该方法允许您指定包含快照呈现属性的SnapshotParameters类的实例。如果为空,将使用默认值。您可以为快照设置以下属性:
-
填充颜色
-
一个转变
-
视口
-
一台照相机
-
深度缓冲器
默认情况下,填充颜色是白色;不使用变换和视口;使用一个ParallelCamera;并且深度缓冲器被设置为假。请注意,这些属性仅在拍摄快照时在节点上使用。
您可以在snapshot()方法中指定一个WritableImage来保存节点的快照。如果这是null,则创建一个新的WritableImage。如果指定的WritableImage小于节点,节点将被裁剪以适应图像大小。
第一个版本的snapshot()方法在WritableImage中返回快照。该图像或者是作为参数传递的图像,或者是由方法创建的新图像。
第二个异步版本的snapshot()方法接受一个Callback对象,其call()方法被调用。一个SnapshotResult对象被传递给call()方法,该方法可用于通过以下方法获得快照映像、源节点和快照参数:
-
WritableImage getImage() -
SnapshotParameters getSnapshotParameters() -
Object getSource()
Tip
snapshot()方法使用节点的boundsInParent属性获取节点的快照。也就是说,快照包含应用于节点的所有效果和变换。如果正在对节点进行动画处理,快照将包括拍摄时节点的动画状态。
清单 21-10 中的程序展示了如何拍摄一个TextField节点的快照。在一个GridPane中显示一个Label,一个TextField,两个Buttons。按钮用于同步和异步拍摄TextField的快照。点击其中一个Buttons拍摄快照。将出现“文件保存”对话框,让您输入保存的快照的文件名。syncSnapshot()和asyncSnapshot()方法包含获取快照的逻辑。对于快照,填充设置为红色,并应用了一个Scale和一个Rotate变换。图 21-6 为快照。
图 21-6
节点的快照
// NodeSnapshot.java
// ...find in the book's download area.
Listing 21-10Taking a Snapshot of a Node
拍摄场景的快照
Scene类包含一个重载的snapshot()方法:
-
WritableImage snapshot(WritableImage image) -
void snapshot(Callback<SnapshotResult,Void> callback, WritableImage image)
比较Scene类和Node类的snapshot()方法。唯一的区别是Scene类中的snapshot()方法不包含SnapshotParameters参数。这意味着您无法自定义场景快照。除此之外,该方法的工作方式与针对Node类的工作方式相同,如前一节所述。
第一个版本的snapshot()方法是同步的,而第二个是异步的。您可以为保存节点快照的方法指定一个WritableImage。如果这是null,则创建一个新的WritableImage。如果指定的WritableImage小于场景,场景将被裁剪以适合图像大小。
清单 21-11 中的程序展示了如何拍摄一个场景的快照。程序中的主要逻辑与清单 21-10 中的程序基本相同,除了这一次,它拍摄了一个场景的快照。图 21-7 显示了快照。
图 21-7
场景的快照
// SceneSnapshot.java
// ...find in the book's download area.
Listing 21-11Taking a Snapshot of a Scene
摘要
JavaFX 提供了 Image API,允许您加载和显示图像,以及读/写原始图像像素。API 中的所有类都在 javafx.scene.image 包中。API 允许您对图像执行以下操作:将图像加载到内存中,将图像显示为场景图中的节点,从图像中读取像素,将像素写入图像,以及将场景图中的节点转换为图像并将其保存到本地文件系统。
Image类的一个实例是一个图像的内存表示。您还可以通过向一个WritableImage实例提供像素来在 JavaFX 应用程序中构造一个图像。Image类支持 BMP、PNG、JPEG 和 GIF 图像格式。它从一个源加载一个图像,这个源可以被指定为一个字符串 URL 或者一个InputStream。它还可以在加载时缩放原始图像。
ImageView类的一个实例用于显示加载到Image对象中的图像。ImageView类继承自Node类,这使得ImageView适合添加到场景图形中。
图像由像素构成。JavaFX 支持从图像中读取像素、向图像中写入像素以及创建场景的快照。它支持从头开始创建图像。如果图像是可写的,您还可以修改内存中的图像,并将其保存到文件系统中。图像 API 提供了对图像中每个像素的访问。它支持一次读写一个像素或一大块像素。
图像中像素的数据可以以不同的格式存储。PixelFormat定义如何存储给定格式的像素数据。WritablePixelFormat表示用全像素颜色信息写入像素的目的格式。
PixelReader和PixelWriter接口定义了从Image读取数据和向WritableImage写入数据的方法。除了一个Image之外,你可以从任何包含像素的表面读取像素,也可以向任何包含像素的表面写入像素。
JavaFX 允许您拍摄下一帧中出现的Node和Scene的快照。您在WritableImage中获取快照,这意味着您可以在获取快照后执行所有像素级操作。Node和Scene类包含一个snapshot()方法来完成这个任务。
下一章将讨论如何使用 Canvas API 在画布上绘图。
二十二、在画布上画画
在本章中,您将学习:
-
什么是画布 API
-
如何创建画布
-
如何在画布上绘图,如基本形状、文本、路径和图像
-
如何清除画布区域
-
如何在
GraphicsContext中保存和恢复绘图状态
本章的例子在com.jdojo.canvas包中。为了让它们工作,您必须在module-info.java文件中添加相应的一行:
...
opens com.jdojo.canvas to javafx.graphics, javafx.base;
...
什么是画布 API?
通过javafx.scene.canvas包,JavaFX 提供了 Canvas API,该 API 提供了一个绘图表面来使用绘图命令绘制形状、图像和文本。该 API 还提供了对绘图表面的像素级访问,您可以在表面上写入任何像素。API 只包含两个类:
-
Canvas -
GraphicsContext
画布是位图图像,用作绘图表面。Canvas类的一个实例代表一个画布。它继承自Node类。因此,画布是一个节点。可以将它添加到场景图中,并对其应用效果和变换。
画布具有与之相关联的图形上下文,用于向画布发出绘制命令。GraphicsContext类的一个实例代表一个图形上下文。
创建画布
Canvas类有两个构造器。无参数构造器创建一个空画布。稍后,您可以使用画布的width和height属性来设置画布的大小。另一个构造器将画布的宽度和高度作为参数:
// Create a Canvas of zero width and height
Canvas canvas = new Canvas();
// Set the canvas size
canvas.setWidth(400);
canvas.setHeight(200);
// Create a 400X200 canvas
Canvas canvas = new Canvas(400, 200);
在画布上画画
一旦创建了画布,就需要使用getGraphicsContext2D()方法获取它的图形上下文,如下面的代码片段所示:
// Get the graphics context of the canvas
GraphicsContext gc = canvas.getGraphicsContext2D();
所有绘图命令都作为方法在GraphicsContext类中提供。超出画布边界的绘图将被剪裁。画布使用缓冲区。绘图命令将必要的参数推送到缓冲区。值得注意的是,在将Canvas添加到场景图形之前,您应该使用来自任何一个线程的图形上下文。一旦Canvas被添加到场景图形中,图形上下文应该只在 JavaFX 应用程序线程上使用。GraphicsContext类包含绘制以下类型对象的方法:
-
基本形状
-
文本
-
小路
-
形象
-
像素
绘制基本形状
GraphicsContext类提供了两种绘制基本形状的方法。方法fillXxx()绘制一个形状Xxx,并用当前的填充颜料填充它。方法strokeXxx()用当前笔画绘制形状Xxx。使用下列方法绘制形状:
-
fillArc() -
fillOval() -
fillPolygon() -
fillRect() -
fillRoundRect() -
strokeArc() -
strokeLine() -
strokeOval() -
strokePolygon() -
strokePolyline() -
strokeRect() -
strokeRoundRect()
下面的代码片段绘制了一个矩形。描边颜色为红色,描边宽度为 2px。矩形的左上角位于(0,0)。矩形宽 100 像素,高 50 像素:
Canvas canvas = new Canvas(200, 100);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setLineWidth(2.0);
gc.setStroke(Color.RED);
gc.strokeRect(0, 0, 100, 50);
绘图文本
您可以使用下面的代码片段,使用GraphicsContext的fillText()和strokeText()方法来绘制文本:
-
void strokeText(String text, double x, double y) -
void strokeText(String text, double x, double y, double maxWidth) -
void fillText(String text, double x, double y) -
void fillText(String text, double x, double y, double maxWidth)
这两个方法都是重载的。一个版本允许您指定文本及其位置。另一个版本允许您指定文本的最大宽度。如果实际文本宽度超过指定的最大宽度,文本将调整大小以适合指定的最大宽度。以下代码片段绘制了两个字符串。图 22-1 显示了画布上的两根弦。
图 22-1
在画布上绘制文本
Canvas canvas = new Canvas(200, 50);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setLineWidth(1.0);
gc.setStroke(Color.BLACK);
gc.strokeText("Drawing Text", 10, 10);
gc.strokeText("Drawing Text", 100, 10, 40);
绘制路径
您可以使用路径命令和 SVG 路径字符串来创建您选择的形状。路径由多个子路径组成。以下方法用于绘制路径:
-
beginPath() -
lineTo(double x1, double y1) -
moveTo(double x0, double y0) -
quadraticCurveTo(double xc, double yc, double x1, double y1) -
appendSVGPath(String svgpath) -
arc(double centerX, double centerY, double radiusX, double radiusY, double startAngle, double length) -
arcTo(double x1, double y1, double x2, double y2, double radius) -
bezierCurveTo(double xc1, double yc1, double xc2, double yc2, double x1, double y1) -
closePath() -
stroke() -
fill()
beginPath()和closePath()方法分别启动和关闭一个路径。像arcTo()和lineTo()这样的方法是绘制特定类型子路径的路径命令。不要忘记在最后调用stroke()或fill()方法,它们将绘制轮廓或填充路径。下面这段代码画了一个三角形,如图 22-2 所示:
图 22-2
画三角形
Canvas canvas = new Canvas(200, 50);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setLineWidth(2.0);
gc.setStroke(Color.BLACK);
gc.beginPath();
gc.moveTo(25, 0);
gc.appendSVGPath("L50, 25L0, 25");
gc.closePath();
gc.stroke();
绘制图像
您可以使用drawImage()方法在画布上绘制图像。该方法有三个版本:
-
void drawImage(Image img, double x, double y) -
void drawImage(Image img, double x, double y, double w, double h) -
void drawImage(Image img, double sx, double sy, double sw, double sh, double dx, double dy, double dw, double dh)
你可以画出图像的全部或一部分。可以在画布上拉伸或缩短绘制的图像。以下代码片段在画布上以原始大小(10,10)绘制了整个图像:
Image image = new Image("your_image_URL");
Canvas canvas = new Canvas(400, 400);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.drawImage(image, 10, 10);
下面的语句将在画布上绘制整个图像,方法是调整图像大小以适合 100 像素宽 150 像素高的区域。图像是拉伸还是缩短取决于其原始大小:
// Draw the whole image in 100X150 area at (10, 10)
gc.drawImage(image, 10, 10, 100, 150);
下面的语句将在画布上绘制图像的一部分。这里,假设源图像大于 100 像素乘 150 像素。正在绘制的图像部分宽 100 像素,高 150 像素,其左上角在源图像中的(0,0)处。图像的一部分以(10,10)绘制在画布上,并被拉伸以适合画布上 200 像素宽和 200 像素高的区域:
// Draw part of the image in 200X200 area at (10, 10)
gc.drawImage(image, 0, 0, 100, 150, 10, 10, 200, 200);
写入像素
你也可以直接在画布上修改像素。GraphicsContext对象的getPixelWriter()方法返回一个PixelWriter,可用于将像素写入关联的画布:
Canvas canvas = new Canvas(200, 100);
GraphicsContext gc = canvas.getGraphicsContext2D();
PixelWriter pw = gc.getPixelWriter();
一旦你得到一个PixelWriter,你就可以把像素写到画布上。第二十一章介绍了更多关于如何使用PixelWriter写像素的细节。
清除画布区域
画布是一个透明区域。像素将具有颜色和不透明度,这取决于在这些像素上绘制的内容。有时,您可能想要清除整个或部分画布,以便像素再次透明。GraphicsContext的clearRect()方法让您清除画布上的指定区域:
// Clear the top-left 100X100 rectangular area from the canvas
gc.clearRect(0, 0, 100, 100);
保存和恢复绘图状态
GraphicsContext的当前设置用于所有后续绘图。例如,如果您将线条宽度设置为 5px,则所有后续笔画的宽度都将为 5px。有时,您可能希望临时修改图形上下文的状态,并在一段时间后恢复修改前的状态。
GraphicsContext对象的save()和restore()方法分别让您保存当前状态和在以后恢复它。在你使用这些方法之前,让我们讨论一下它的必要性。假设您想按顺序向GraphicsContext对象发出以下命令:
-
画一个没有任何效果的矩形
-
绘制具有反射效果的字符串
-
画一个没有任何效果的矩形
以下是实现这一点的第一次(也是不正确的)尝试:
Canvas canvas = new Canvas(200, 120);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.strokeRect(10, 10, 50, 20);
gc.setEffect(new Reflection());
gc.strokeText("Chatar", 70, 20);
gc.strokeRect(120, 10, 50, 20);
图 22-3 为画布的绘制。请注意,反射效果也应用于第二个矩形,这是不希望的。
图 22-3
绘制形状和文本
您可以在绘制文本后通过将Effect设置为null来解决这个问题。您已经修改了GraphicsContext的几个属性,然后必须手动恢复它们。有时,一个GraphicsContext可能被传递给你的代码,但是你不想修改它的现有状态。
save()方法存储堆栈上GraphicsContext的当前状态。restore()方法将GraphicsContext的状态恢复到上次保存的状态。图 22-4 显示了这样的结果。您可以使用以下方法解决该问题:
图 22-4
使用save()和restore()方法绘制形状和文本
Canvas canvas = new Canvas(200, 120);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.strokeRect(10, 10, 50, 20);
// Save the current state
gc.save();
// Modify the current state to add an effect and darw the text
gc.setEffect(new Reflection());
gc.strokeText("Chatar", 70, 20);
// Restore the state what it was when the last save() was called and draw the
// second rectangle
gc.restore();
gc.strokeRect(120, 10, 50, 20);
一个画布绘画的例子
清单 22-1 中的程序展示了如何在画布上绘制基本的形状、文本、图像和行像素。图 22-5 显示了所有绘图的结果画布。
图 22-5
上面绘制有形状、文本、图像和原始像素的画布
// CanvasTest.java
package com.jdojo.canvas;
import com.jdojo.util.ResourceUtil;
import java.nio.ByteBuffer;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.PixelWriter;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
public class CanvasTest extends Application {
private static final int RECT_WIDTH = 20;
private static final int RECT_HEIGHT = 20;
public static void main(String[] args) {
Application.launch(args);
}
@Override
public void start(Stage stage) {
Canvas canvas = new Canvas(400, 100);
GraphicsContext gc = canvas.getGraphicsContext2D();
// Set line width and fill color
gc.setLineWidth(2.0);
gc.setFill(Color.RED);
// Draw a rounded rectangle
gc.strokeRoundRect(10, 10, 50, 50, 10, 10);
// Fill an oval
gc.fillOval(70, 10, 50, 20);
// Draw text
gc.strokeText("Hello Canvas", 10, 85);
// Draw an Image
String imagePath =
ResourceUtil.getResourceURLStr("picture/ksharan.jpg");
Image image = new Image(imagePath);
gc.drawImage(image, 130, 10, 60, 80);
// Write custom pixels to create a pattern
writePixels(gc);
Pane root = new Pane();
root.getChildren().add(canvas);
Scene scene = new Scene(root);
stage.setScene(scene);
stage.setTitle("Drawing on a Canvas");
stage.show();
}
private void writePixels(GraphicsContext gc) {
byte[] pixels = this.getPixelsData();
PixelWriter pixelWriter = gc.getPixelWriter();
// Our data is in BYTE_RGB format
PixelFormat<ByteBuffer> pixelFormat =
PixelFormat.getByteRgbInstance();
int spacing = 5;
int imageWidth = 200;
int imageHeight = 100;
// Roughly compute the number of rows and columns
int rows = imageHeight/(RECT_HEIGHT + spacing);
int columns = imageWidth/(RECT_WIDTH + spacing);
// Write the pixels to the canvas
for (int y = 0; y < rows; y++) {
for (int x = 0; x < columns; x++) {
int xPos = 200 + x * (RECT_WIDTH + spacing);
int yPos = y * (RECT_HEIGHT + spacing);
pixelWriter.setPixels(xPos, yPos,
RECT_WIDTH, RECT_HEIGHT,
pixelFormat,
pixels, 0,
RECT_WIDTH * 3);
}
}
}
private byte[] getPixelsData() {
// Each pixel in the w X h region will take 3 bytes
byte[] pixels = new byte[RECT_WIDTH * RECT_HEIGHT * 3];
// Height to width ration
double ratio = 1.0 * RECT_HEIGHT/RECT_WIDTH;
// Generate pixel data
for (int y = 0; y < RECT_HEIGHT; y++) {
for (int x = 0; x < RECT_WIDTH; x++) {
int i = y * RECT_WIDTH * 3 + x * 3;
if (x <= y/ratio) {
pixels[i] = -1; // red -1 means
// 255 (-1 & 0xff = 255)
pixels[i+1] = 0; // green = 0
pixels[i+2] = 0; // blue = 0
} else {
pixels[i] = 0; // red = 0
pixels[i+1] = -1; // Green 255
pixels[i+2] = 0; // blue = 0
}
}
}
return pixels;
}
}
Listing 22-1Drawing on a Canvas
摘要
通过javafx.scene.canvas包,JavaFX 提供了 Canvas API,该 API 提供了一个绘图表面来使用绘图命令绘制形状、图像和文本。该 API 还提供了对绘图表面的像素级访问,您可以在表面上写入任何像素。这个 API 只包含两个类:Canvas和GraphicsContext。画布是位图图像,用作绘图表面。Canvas类的一个实例代表一个画布。它继承自Node类。因此,画布是一个节点。可以将它添加到场景图中,并对其应用效果和变换。画布具有与之相关联的图形上下文,用于向画布发出绘制命令。GraphicsContext类的一个实例代表一个图形上下文。
Canvas类包含一个返回GraphicsContext类实例的getGraphicsContext2D()方法。获得画布的GraphicsContext后,向执行绘制的GraphicsContext发出绘制命令。
超出画布边界的绘图将被剪裁。画布使用缓冲区。绘图命令将必要的参数推送到缓冲区。在画布被添加到场景图之前,可以从任何一个线程使用画布的GraphicsContext。一旦画布被添加到场景图形中,图形上下文应该只在 JavaFX 应用程序线程上使用。GraphicsContext类包含绘制以下类型对象的方法:基本形状、文本、路径、图像和像素。
下一章将讨论如何使用拖放手势在同一个 JavaFX 应用程序的节点之间、两个不同的 JavaFX 应用程序之间以及 JavaFX 应用程序和本机应用程序之间传输数据。
二十三、理解拖放
在本章中,您将学习:
-
什么是按下-拖动-释放手势
-
如何使用拖板来促进数据传输
-
如何启动和检测拖放动作
-
如何使用拖放动作将数据从源传输到目标
-
如何使用拖放手势传输图像
-
如何使用拖放动作在源和目标之间传输自定义数据
本章的例子在com.jdojo.dnd包中。为了让它们工作,您必须在module-info.java文件中添加相应的一行:
...
opens com.jdojo.dnd to javafx.graphics, javafx.base;
...
什么是按下-拖动-释放手势?
按下-拖动-释放手势是按下鼠标按钮、用按下的按钮拖动鼠标并释放按钮的用户动作。手势可以在场景或节点上启动。几个节点和场景可以参与单个按压-拖动-释放手势。该手势能够生成不同类型的事件,并将这些事件传递给不同的节点。生成的事件和接收事件的节点的类型取决于手势的目的。可以出于不同的目的拖动节点:
-
您可能希望通过拖动节点的边界来更改节点的形状,或者通过将其拖动到新位置来移动节点。在这种情况下,手势只涉及一个节点:启动手势的节点。
-
您可能希望将一个节点拖放到另一个节点上,以某种方式连接它们,例如,在流程图中用符号连接两个节点。在这种情况下,拖动手势涉及多个节点。当源节点被放到目标节点上时,会发生一个动作。
-
您可以将一个节点拖放到另一个节点上,将数据从源节点传输到目标节点。在这种情况下,拖动手势涉及多个节点。当源节点断开时,会发生数据传输。
JavaFX 支持三种类型的拖动手势:
-
简单的按下-拖动-释放手势
-
完全按下-拖动-释放手势
-
拖放手势
本章将主要关注第三种手势:拖放手势。要全面了解拖放手势,理解前两种手势是非常重要的。我将简要讨论前两种类型的手势,每种类型都有一个简单的例子。
简单的按下-拖动-释放手势
简单的按下-拖动-释放手势是默认的拖动手势。当拖动笔势只涉及一个节点(笔势在其上启动的节点)时使用。在拖动手势过程中,所有的MouseDragEvent类型——鼠标拖动输入、鼠标拖动结束、鼠标拖动退出、鼠标和鼠标拖动释放——都只传递给手势源节点。在这种情况下,当按下鼠标按钮时,会选取最顶层的节点,所有后续的鼠标事件都会传递到该节点,直到松开鼠标按钮。当鼠标被拖动到另一个节点上时,手势开始所在的节点仍然在光标下,因此,在释放鼠标按钮之前,没有其他节点接收事件。
清单 23-1 中的程序演示了一个简单的按压-拖动-释放手势的例子。它向场景添加了两个TextFields:一个称为源节点,另一个称为目标节点。事件处理程序被添加到这两个节点中。目标节点添加了MouseDragEvent处理程序来检测其上的任何鼠标拖动事件。运行程序,在源节点上按下鼠标按钮,将其拖到目标节点上,最后,释放鼠标按钮。下面的输出显示源节点接收所有鼠标拖动的事件。目标节点不接收任何鼠标拖动事件。这是简单的按下-拖动-释放手势的情况,其中启动拖动手势的节点接收所有鼠标拖动事件。
// SimplePressDragRelease.java
package com.jdojo.dnd;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.stage.Stage;
public class SimplePressDragRelease extends Application {
TextField sourceFld = new TextField("Source Node");
TextField targetFld = new TextField("Target node");
public static void main(String[] args) {
Application.launch(args);
}
@Override
public void start(Stage stage) {
// Build the UI
GridPane root = getUI();
// Add event handlers
this.addEventHandlers();
Scene scene = new Scene(root);
stage.setScene(scene);
stage.setTitle("A simple press-drag-release gesture");
stage.show();
}
private GridPane getUI() {
GridPane pane = new GridPane();
pane.setHgap(5);
pane.setVgap(20);
pane.addRow(0, new Label("Source Node:"), sourceFld);
pane.addRow(1, new Label("Target Node:"), targetFld);
return pane;
}
private void addEventHandlers() {
// Add mouse event handlers for the source
sourceFld.setOnMousePressed(e ->
print("Source: pressed"));
sourceFld.setOnMouseDragged(e ->
print("Source: dragged"));
sourceFld.setOnDragDetected(e ->
print("Source: dragged detected"));
sourceFld.setOnMouseReleased(e ->
print("Source: released"));
// Add mouse event handlers for the target
targetFld.setOnMouseDragEntered(e ->
print("Target: drag entered"));
targetFld.setOnMouseDragOver(e ->
print("Target: drag over"));
targetFld.setOnMouseDragReleased(e ->
print("Target: drag released"));
targetFld.setOnMouseDragExited(e ->
print("Target: drag exited"));
}
private void print(String msg) {
System.out.println(msg);
}
}
Source: Mouse pressed
Source: Mouse dragged
Source: Mouse dragged detected
Source: Mouse dragged
Source: Mouse dragged
...
Source: Mouse released
Listing 23-1Demonstrating a Simple Press-Drag-Release Gesture
请注意,拖动鼠标后会生成一次检测到拖动事件。MouseEvent对象有一个dragDetect标志,可以在鼠标按下和鼠标拖动事件中设置。如果设置为 true,则生成的后续事件是检测到拖动事件。默认情况下是在鼠标拖动事件之后生成它。如果您想在鼠标按下事件之后生成它,而不是鼠标拖动事件之后,您需要修改事件处理程序:
sourceFld.setOnMousePressed(e -> {
print("Source: Mouse pressed");
// Generate drag detect event after the current mouse pressed event
e.setDragDetect(true);
});
sourceFld.setOnMouseDragged(e -> {
print("Source: Mouse dragged");
// Suppress the drag detected default event generation after mouse
// dragged
e.setDragDetect(false);
});
完全按下-拖动-释放手势
当拖动手势的源节点接收到检测到拖动事件时,您可以通过调用源节点上的startFullDrag()方法来启动一个全按-拖动-释放手势。startFullDrag()方法存在于Node和Scene类中,允许你为一个节点和一个场景启动一个完整的按下-拖动-释放手势。在这次讨论中,我将只使用术语节点。
Tip
只能从检测到拖动的事件处理程序中调用startFullDrag()方法。从任何其他地方调用这个方法都会抛出一个IllegalStateException。
您需要再做一次设置才能看到完整的按下-拖动-释放手势。拖动动作的源节点仍将接收所有鼠标拖动的事件,因为它在发生拖动时位于光标之下。您需要将手势源的mouseTransparent属性设置为 false,这样它下面的节点将被选取,鼠标拖动的事件将被传递到该节点。在鼠标按下事件中将此属性设置为 true,在鼠标释放事件中将它设置回 false。
清单 23-2 中的程序演示了一个完整的按下-拖动-释放手势。该程序类似于清单 23-1 中所示的程序,除了以下几点:
-
在源节点的鼠标按下事件处理程序中,源节点的
mouseTransparent属性被设置为 false。它在释放鼠标的事件处理程序中被设置回 true。 -
在检测到拖动的事件处理程序中,在源节点上调用
startFullDrag()方法。
运行程序,在源节点上按下鼠标按钮,将其拖到目标节点上,最后,释放鼠标按钮。下面的输出显示,当鼠标被拖动到其边界内时,目标节点接收到鼠标拖动事件。这是完全按下-拖动-释放手势的情况,其中发生鼠标拖动的节点接收鼠标拖动事件。
// FullPressDragRelease.java
// ...find in the book's download area.
Source: Mouse pressed
Source: Mouse dragged
Source: Mouse dragged
Source: Mouse dragged detected
Source: Mouse dragged
Source: Mouse dragged
Target: drag entered
Target: drag over
Source: Mouse dragged
Target: drag over
Target: drag released
Source: Mouse released
Target: drag exited
Listing 23-2Demonstrating a Full Press-Drag-Release Gesture
拖放手势
第三种类型的拖动手势称为拖放手势,这是一种结合了鼠标移动和按下鼠标按钮的用户动作。用于将数据从手势源传输到手势目标。拖放动作允许将数据从
-
一个节点到另一个节点
-
场景的节点
-
一幕接一幕
-
场景到节点
源和目标可以在同一个 Java 或 JavaFX 应用程序中,也可以在两个不同的 Java 或 JavaFX 应用程序中。JavaFX 应用程序和本机应用程序也可以参与手势,例如:
-
您可以将文本从 Microsoft Word 应用程序拖到 JavaFX 应用程序来填充
TextArea,反之亦然。 -
您可以将图像文件从 Windows 资源管理器中拖放到 JavaFX 应用程序中的
ImageView上。ImageView可以显示图像。 -
您可以从 Windows 资源管理器中拖放一个文本文件到 JavaFX 应用程序中的
TextArea上。TextArea将读取文件并显示其内容。
执行拖放动作涉及几个步骤:
-
在节点上按下了鼠标按钮。
-
按住按钮拖动鼠标。
-
该节点接收拖动检测事件。
-
通过调用
startDragAndDrop()方法在节点上启动拖放动作,使节点成为动作源。来自源节点的数据放在一个 dragboard 中。 -
一旦系统切换到拖放手势,它就停止传送
MouseEvents并开始传送DragEvents。 -
手势源被拖到潜在的手势目标上。潜在的手势目标检查它是否接受放置在 dragboard 中的数据。如果它接受数据,它可能成为实际的手势目标。节点指示它是否接受它的一个
DragEvent处理程序中的数据。 -
用户释放手势目标上按下的按钮,向其发送拖放事件。
-
手势目标使用来自 dragboard 的数据。
-
drag-done 事件被发送到手势源,指示拖放手势完成。
我将在接下来的小节中详细讨论所有这些步骤。支持拖放手势的类包含在javafx.scene.input包中。
了解数据传输模式
在拖放手势中,数据可以通过三种模式传输:
-
复制
-
移动
-
环
复制模式表示数据将从手势源复制到手势目标。您可以将一个TextField拖放到另一个TextField上。后者获得前者中包含的文本的副本。
移动模式表示数据将从手势源移动到手势目标。您可以将一个TextField拖放到另一个TextField上。前者中的文本随后被移到后者中。
链接模式表示手势目标将创建一个链接(或引用)到正在传输的数据。“链接”的实际含义取决于应用。您可以在链接模式下将 URL 拖放到WebView中。然后,WebView加载 URL 内容。
三种数据传输模式由TransferMode枚举中的以下三个常量表示:
-
TransferMode.COPY -
TransferMode.MOVE -
TransferMode.LINK
有时,您可能需要三种传输模式的组合。TransferMode枚举包含三个方便的静态字段,它们是枚举常量的数组:
-
TransferMode[] ANY -
TransferMode[] COPY_OR_MOVE -
TransferMode[] NONE
ANY字段是一个由COPY、MOVE和LINK枚举常量组成的数组。COPY_OR_MOVE字段是COPY和MOVE枚举常量的数组。NONE常量是一个空数组。
每个拖放动作都包括使用TransferMode枚举常量。手势源指定其支持的数据传输模式。手势目标指定它接受数据传输的模式。
了解拖板
在拖放数据传输中,手势源和手势目标彼此不认识。事实上,它们可能属于两个不同的应用程序:两个 JavaFX 应用程序,或者一个 JavaFX 和一个 native。如果手势源和目标彼此不认识,它们之间的数据传输是如何发生的?在现实世界中,需要一个中介来促进两个未知方之间的交易。在拖放手势中,也使用中介来促进数据传输。
拖板充当手势源和手势目标之间的中介。拖板是保存正在传输的数据的存储设备。手势源将数据放入拖板中;dragboard 可供手势目标使用,因此它可以检查可用于传输的内容类型。当手势目标准备好传输数据时,它从 dragboard 获取数据。图 23-1 显示了拖板所扮演的角色。
图 23-1
拖放手势中的数据传输机制
Dragboard类的一个实例代表一个 dragboard。该类继承自Clipboard类。一个Clipboard类的实例代表一个操作系统剪贴板。通常,操作系统在剪切、复制和粘贴操作中使用剪贴板来存储数据。使用Clipboard类的静态getSystemClipboard()方法可以得到操作系统通用剪贴板的引用:
Clipboard systemClipboard = Clipboard.getSystemClipboard();
您可以将数据放在系统剪贴板中,系统中的所有应用程序都可以访问这些数据。您可以读取放置在系统剪贴板中的数据,这些数据可以由任何应用程序放置在那里。剪贴板可以存储不同类型的数据,例如,RTF 文本、纯文本、HTML、URL、图像或文件。该类包含几个方法来检查剪贴板中是否有特定格式的数据。如果特定格式的数据可用,这些方法返回true。例如,如果剪贴板包含一个普通字符串,hasString()方法返回true;hasRtf()方法为富文本格式的文本返回true。该类包含以特定格式检索数据的方法。例如,getString()方法以纯文本格式返回数据;getHtml()返回 HTML 文本;getImage()返回图像;等等。clear()方法清除剪贴板。
Tip
您不能直接创建Clipboard类的实例。剪贴板是为了存储一个概念上的项目。概念一词意味着剪贴板中的数据可能以不同的格式存储,表示同一项。例如,您可以存储 RTF 文本及其纯文本版本。在这种情况下,剪贴板有相同项目的两个不同格式的副本。
剪贴板不限于仅存储固定数量的数据类型。任何可序列化的数据都可以存储在剪贴板上。存储在剪贴板上的数据具有相关联的数据格式。DataFormat类的一个实例代表一种数据格式。DataFormat类包含六个静态字段来表示常用的数据格式:
-
FILES -
HTML -
IMAGE -
PLAIN_TEXT -
RTF -
URL
FILES表示一列java.io.File对象。HTML代表一个 HTML 格式的字符串。IMAGE表示特定于平台的图像类型。PLAIN_TEXT代表一个纯文本字符串。RTF代表一个 RTF 格式的字符串。URL表示一个编码为字符串的 URL。
您可能希望将剪贴板中的数据存储为不同于前面列出的格式。您可以创建一个DataFormat对象来表示任意格式。您需要为您的数据格式指定一个 mime 类型列表。以下语句创建一个将jdojo/person和jdojo/personlist作为 mime 类型的DataFormat:
DataFormat myFormat = new DataFormat("jdojo/person", "jdojo/person");
Clipboard类提供了以下方法来处理数据及其格式:
-
boolean setContent(Map<DataFormat,Object> content) -
Object getContent(DataFormat dataFormat)
剪贴板的内容是一个以DataFormat为键,以数据为值的映射。如果剪贴板中没有特定数据格式的数据,则getContent()方法返回null。以下代码片段存储 HTML 和纯文本版本的数据,并在以后检索这两种格式的数据:
// Store text in HTML and plain-text formats in the system clipboard
Clipboard clipboard = Clipboard.getSystemClipboard();
Map<DataFormat,Object> data = new HashMap<>();
data.put(DataFormat.HTML, "<b>Yahoo!</b>");
data.put(DataFormat.PLAIN_TEXT, "Yahoo!");
clipboard.setContent(data);
...
// Try reading HTML text and plain text from the clipboard
If (clipboard.hasHtml()) {
String htmlText = (String)clipboard.getContent(DataFormat.HTML);
System.out.println(htmlText);
}
If (clipboard.hasString()) {
String plainText = (String)clipboard.getContent(DataFormat.PLAIN_TEXT);
System.out.println(plainText);
}
准备存储在剪贴板中的数据需要编写一点臃肿的代码。ClipboardContent类的一个实例表示剪贴板的内容,它使得使用剪贴板数据变得更加容易。该类继承自HashMap<DataFormat,Object>类。它以putXxx()和getXxx()的形式为常用的数据类型提供了方便的方法。下面的代码片段重写了前面的逻辑,将数据存储到剪贴板中。检索数据的逻辑保持不变:
Clipboard clipboard = Clipboard.getSystemClipboard();
ClipboardContent content = new ClipboardContent();
content.putHtml("<b>Yahoo!</b>");
content.putString("Yahoo!");
clipboard.setContent(content);
Dragboard类继承了Clipboard类中所有可用的公共方法。它添加了以下方法:
-
Set<TransferMode> getTransferModes() -
void setDragView(Image image) -
void setDragView(Image image, double offsetX, double offsetY) -
void setDragViewOffsetX(double offsetX) -
void setDragViewOffsetY(double offsetY) -
Image getDragView() -
Double getDragViewOffsetX() -
double getDragViewOffsetY()
getTransferModes()方法返回手势目标支持的传输模式集。setDragView()方法将图像设置为拖动视图。拖动手势源时会显示图像。偏移量是光标在图像上的 x 和 y 位置。其他方法包括获取拖动视图图像和光标偏移量。
Tip
dragboard 是一种用于拖放动作的特殊系统剪贴板。您不能显式创建 dragboard。每当需要使用 dragboard 时,它的引用将作为方法的返回值或事件对象的属性提供。例如,DragEvent类包含一个getDragboard()方法,该方法返回包含被传输数据的Dragboard的引用。
示例应用程序
在接下来的部分中,我将详细讨论拖放动作的步骤,并且您将构建一个示例应用程序。应用程序将有两个TextFields显示在一个场景中。一个文本字段称为源节点,另一个称为目标节点。用户可以将源节点拖放到目标节点上。完成手势后,来自源节点的文本被传输(复制或移动)到目标节点。我将在讨论中提到这些节点。它们声明如下:
TextField sourceFld = new TextField("Source node");
TextField targetFld = new TextField("Target node");
启动拖放手势
拖放手势的第一步是将简单的按下-拖动-释放手势转换为拖放手势。这是在手势源的鼠标拖动检测事件处理程序中完成的。在手势源上调用startDragAndDrop()方法会启动一个拖放手势。该方法在Node和Scene类中可用,因此一个节点和一个场景可以是拖放手势的手势源。方法签名是
Dragboard startDragAndDrop(TransferMode... transferModes)
该方法接受笔势源支持的传输模式列表,并返回一个 dragboard。手势源需要用它想要传输的数据填充 dragboard。下面的代码片段启动一个拖放动作,将源TextField文本复制到 dragboard,并使用该事件。拖放手势仅在TextField包含文本时启动:
sourceFld.setOnDragDetected((MouseEvent e) -> {
// User can drag only when there is text in the source field
String sourceText = sourceFld.getText();
if (sourceText == null || sourceText.trim().equals("")) {
e.consume();
return;
}
// Initiate a drag-and-drop gesture
Dragboard dragboard =
sourceFld.startDragAndDrop(TransferMode.COPY_OR_MOVE);
// Add the source text to the Dragboard
ClipboardContent content = new ClipboardContent();
content.putString(sourceText);
dragboard.setContent(content);
e.consume();
});
检测拖动手势
一旦启动了拖放手势,您就可以将手势源拖到任何其他节点上。手势源已经将数据放入 dragboard,声明它支持的传输模式。现在是潜在的手势目标声明它们是否接受手势源提供的数据传输的时候了。请注意,可能有多个潜在的手势目标。当手势源放在其中一个目标上时,它将成为实际的手势目标。
潜在手势目标接收几种类型的拖动事件:
-
当手势源进入它的边界时,它接收一个拖动输入事件。
-
当在它的边界内拖动手势源时,它接收一个拖动事件。
-
当笔势源退出其边界时,它接收一个拖动退出事件。
-
当通过释放鼠标按钮将手势源放在它上面时,它接收一个拖放事件。
在拖动事件处理程序中,潜在的手势目标需要通过调用DragEvent的acceptTransferModes(TransferMode... modes)方法来声明它打算参与拖放手势。通常,潜在目标在声明是否接受传输模式之前会检查 dragboard 的内容。下面的代码片段实现了这一点。目标TextField检查 dragboard 中的纯文本。它包含纯文本,因此目标声明它接受COPY和MOVE传输模式:
targetFld.setOnDragOver((DragEvent e) -> {
// If drag board has a string, let the event know that the
// target accepts copy and move transfer modes
Dragboard dragboard = e.getDragboard();
if(dragboard.hasString()) {
e.acceptTransferModes(TransferMode.COPY_OR_MOVE);
}
e.consume();
});
将源放到目标上
如果潜在手势目标接受手势源支持的转移模式,则手势源可被放到目标上。当手势源仍在目标上方时,通过释放鼠标按钮来完成放下。当手势源放到目标上时,该目标成为实际的手势目标。实际的手势目标接收拖放事件。您需要为手势目标添加一个拖放事件处理程序,它在其中执行两个任务:
-
它访问 dragboard 中的数据。
-
它调用
DragEvent对象的setDropCompleted(boolean isTransferDone)方法。
将 true 传递给方法指示数据传输成功。传递 false 表示数据传输不成功。调用此方法后,无法访问 dragboard。
以下代码片段执行数据传输并设置适当的完成标志:
targetFld.setOnDragDropped((DragEvent e) -> {
// Transfer the data to the target
Dragboard dragboard = e.getDragboard();
if(dragboard.hasString()) {
String text = dragboard.getString();
targetFld.setText(text);
// Data transfer is successful
e.setDropCompleted(true);
} else {
// Data transfer is not successful
e.setDropCompleted(false);
}
e.consume();
});
完成拖放动作
放下手势源后,它会收到一个 drag-done 事件。DragEvent对象包含一个getTransferMode()方法。当从 drag-done 事件处理程序调用它时,它返回用于数据传输的传输模式。根据传输模式,您可以清除或保留手势源的内容。例如,如果传输模式是MOVE,最好清除源内容,让用户真正感受到数据移动。
您可能想知道是什么决定了数据传输模式。在这个例子中,手势源和目标都支持COPY和MOVE。当目标在拖放事件中从 dragboard 访问数据时,它没有设置任何传输模式。系统根据某些键的状态以及源和目标来确定数据传输模式。例如,当您将一个TextField拖放到另一个TextField上时,默认的数据传输模式是MOVE。当按住 Ctrl 键执行相同的拖放操作时,会使用COPY模式。
如果getTransferMode()方法返回null或TransferMode.ONE,则表明没有发生数据传输。下面的代码片段处理源TextField的拖动完成事件。如果数据传输模式是MOVE,源文本被清除:
sourceFld.setOnDragDone((DragEvent e) -> {
// Check how the data transfer happened. If it was moved, clear the
// text in the source.
TransferMode modeUsed = e.getTransferMode();
if (modeUsed == TransferMode.MOVE) {
sourceFld.setText("");
}
e.consume();
});
这就完成了对拖放手势的处理。如果你需要更多关于参与拖放动作的各方的信息,请参考DragEvent类的 API 文档。例如,使用getGestureSource()和getGestureTarget()方法分别获取手势源和目标的引用。
提供视觉线索
有几种方法可以在拖放动作中提供视觉线索:
-
在拖动手势期间,系统会在光标下提供一个图标。图标会根据系统确定的传输模式以及拖动目标是否是拖放手势的潜在目标而变化。
-
您可以通过更改潜在目标的可视外观,为其拖动进入和拖动退出事件编写代码。例如,在拖动输入的事件处理程序中,如果允许数据传输,您可以将潜在目标的背景颜色更改为绿色,如果不允许,则更改为红色。在拖动退出事件处理程序中,您可以将背景颜色改回正常颜色。
-
您可以在手势的 drag-detected 事件处理程序中的 dragboard 中设置拖动视图。拖动视图是一个图像。例如,您可以拍摄被拖动的节点或部分节点的快照,并将其设置为拖动视图。
一个完整的拖放示例
清单 23-3 中的程序有这个例子的完整源代码。显示如图 23-2 所示的窗口。您可以拖动手势源TextField并将其放到目标TextField上。源中的文本将被复制或移动到目标中。传输模式取决于系统。例如,在 Windows 上,在放下时按下 Ctrl 键将复制文本,在没有按下 Ctrl 键的情况下放下将移动文本。请注意,在拖动动作过程中,拖动图标会发生变化。当您放下信号源时,图标会提示您将会发生何种数据传输。例如,当您将源拖到不接受源提供的数据传输的目标上时,会显示一个“不允许”图标,即一个带有斜实线的圆圈。
图 23-2
允许使用拖放手势将文本从一个TextField转移到另一个的场景
// DragAndDropTest.java
// ...find in the book's download area.
Listing 23-3Performing a Drag-and-Drop Gesture
传输图像
拖放手势允许您传输图像。图像可以放在拖板上。您也可以在拖板上放置一个指向图像位置的 URL 或文件。让我们开发一个简单的应用程序来演示图像数据传输。要传输图像,用户可以将以下内容拖放到场景中:
-
图像
-
图像文件
-
指向图像的 URL
清单 23-4 中的程序打开一个窗口,有一条文本消息、一个空的ImageView和一个按钮。ImageView将显示拖放的图像。使用按钮清除图像。
整个场景都是拖放动作的潜在目标。为场景设置了一个拖动事件处理程序。它检查拖板是否包含图像、文件列表或 URL。如果它在 dragboard 中找到这些数据类型中的一种,它将报告它将接受任何数据传输模式。在场景的拖放事件处理程序中,程序尝试按顺序读取图像数据、文件列表和 URL。如果是文件列表,那么查看每个文件的 mime 类型,看文件名是否以image/开头。您使用带有图像 mime 类型的第一个文件,忽略其余的文件。如果它是一个 URL,您只需尝试从它创建一个Image对象。您可以用不同的方式使用该应用程序:
-
运行程序并在浏览器中打开 HTML 文件
drag_and_drop.html。该文件包含在src/resources/html目录中。HTML 文件包含两个链接:一个指向本地图像文件,另一个指向远程图像文件。将链接拖放到场景中。该场景将显示链接所引用的图像。从网页中拖放图像。场景将显示图像。(图像的拖放在 Mozilla 和 Google Chrome 浏览器中运行良好,但在 Windows 资源管理器中就不行了。) -
打开文件资源管理器,例如 Windows 上的 Windows 资源管理器。选择一个图像文件,并将该文件拖放到场景中。场景将显示文件中的图像。您可以放下多个文件,但是场景将只显示其中一个文件的图像。
您可以通过允许用户将多个文件拖到场景中并在一个TilePane中显示它们来增强应用程序。您还可以添加更多关于拖放动作的错误检查和反馈给用户。
// ImageDragAndDrop.java
// ...find in the book's download area.
Listing 23-4Transferring an Image Using a Drag-and-Drop Gesture
传输自定义数据类型
如果数据是Serializable,您可以使用拖放手势传输任何格式的数据。在这一节中,我将演示如何传输自定义数据。你要转一个ArrayList<Item>。Item级如清单 23-5 所示;是Serializable。这个类非常简单。它包含一个私有字段及其 getter 和 setter 方法。
// Item.java
package com.jdojo.dnd;
import java.io.Serializable;
public class Item implements Serializable {
private String name = "Unknown";
public Item(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return name;
}
}
Listing 23-5Using a Custom Data Type in Data Transfer
清单 23-6 中的程序展示了如何在拖放动作中使用自定义数据格式。显示如图 23-3 所示的窗口。该窗口包含两个ListViews。最初,只有一个ListViews填充了一个项目列表。两个ListViews都支持多选。您可以选择一个ListView中的项目,并将其拖放到另一个ListView中。将根据系统确定的传输模式复制或移动选定的项目。例如,在 Windows 上,默认情况下会移动项目。如果您在拖放时按下 Ctrl 键,项目将被复制。
图 23-3
在两个ListViews之间传送所选项目的列表
// CustomDataTransfer.java
// ...find in the book's download area.
Listing 23-6Transferring Custom Data Using a Drag-and-Drop Gesture
大部分程序和你以前看过的差不多。区别在于如何在 dragboard 中存储和检索ArrayList<Item>。
您为该数据传输定义了一个新的数据格式,因为该数据不符合任何作为DataFormat类中的常量的类别。您必须将数据定义为常量,如以下代码所示:
// Our custom Data Format
static final DataFormat ITEM_LIST = new DataFormat("jdojo/itemlist");
现在,您已经为数据格式给出了一个惟一的 mime 类型jdojo/itemlist。
在 drag-detected 事件中,您需要将选定项目的列表存储到 dragboard 上。下面的代码片段在dragDetected()方法中存储作业。请注意,在拖板上存储数据时,您使用了新的数据格式:
ArrayList<Item> selectedItems = this.getSelectedItems(listView);
ClipboardContent content = new ClipboardContent();
content.put(ITEM_LIST, selectedItems);
dragboard.setContent(content);
在拖过事件中,如果ListView没有被拖过自身,并且拖板包含ITEM_LIST数据格式的数据,ListView声明它接受COPY或MOVE传输。下面的代码片段在dragOver()方法中完成了这项工作:
Dragboard dragboard = e.getDragboard();
if (e.getGestureSource() != listView && dragboard.hasContent(ITEM_LIST)) {
e.acceptTransferModes(TransferMode.COPY_OR_MOVE);
}
最后,当源拖放到目标上时,您需要从 dragboard 中读取数据。您需要使用 dragboard 的getContent()方法,将ITEM_LIST指定为数据格式。返回的结果需要被强制转换为ArrayList<Item>。下面的代码片段在dragDropped()方法中完成了这项工作:
Dragboard dragboard = e.getDragboard();
if(dragboard.hasContent(ITEM_LIST)) {
ArrayList<Item> list =
(ArrayList<Item>)dragboard.getContent(ITEM_LIST);
listView.getItems().addAll(list);
// Data transfer is successful
dragCompleted = true;
}
最后,在用dragDone()方法实现的拖动完成事件处理程序中,如果将MOVE用作传输模式,则从源ListView中移除选定的项目。注意,您已经使用了一个ArrayList<Item>,因为ArrayList和Item类都是可序列化的。
摘要
按下-拖动-释放手势是按下鼠标按钮、用按下的按钮拖动鼠标并释放按钮的用户动作。手势可以在场景或节点上启动。几个节点和场景可以参与单个按压-拖动-释放手势。该手势能够生成不同类型的事件,并将这些事件传递给不同的节点。生成事件的类型和接收事件的节点取决于手势的目的。
JavaFX 支持三种类型的拖动手势:简单的按下-拖动-释放手势、完全按下-拖动-释放手势和拖放手势。
简单的按下-拖动-释放手势是默认的拖动手势。当拖动笔势只涉及一个节点(笔势在其上启动的节点)时使用。在拖动手势过程中,所有的MouseDragEvent类型——鼠标拖动输入、鼠标拖动结束、鼠标拖动退出、鼠标和鼠标拖动释放——都只传递给手势源节点。
当拖动手势的源节点接收到检测到拖动的事件时,您可以通过调用源节点上的startFullDrag()方法来启动一个完整的按下-拖动-释放手势。startFullDrag()方法存在于Node和Scene类中,允许你为一个节点和一个场景启动一个完整的按下-拖动-释放手势。
第三种类型的拖动手势称为拖放手势,这是一种将鼠标移动与按下鼠标按钮相结合的用户动作。它用于将数据从手势源传输到手势目标。在拖放动作中,数据可以通过三种模式传输:复制、移动和链接。复制模式表示数据将从手势源复制到手势目标。移动模式表示数据将从手势源移动到手势目标。链接模式指示手势目标将创建到正在传输的数据的链接(或引用)。“链接”的实际含义取决于应用。
在拖放数据传输中,手势源和手势目标彼此不认识,它们甚至可能属于两个不同的应用程序。dragboard 充当手势源和手势目标之间的中介。dragboard 是保存正在传输的数据的存储设备。手势源将数据放在拖板上;dragboard 可供手势目标使用,因此它可以检查可用于传输的内容类型。当手势目标准备好传输数据时,它从 dragboard 获取数据。
使用拖放手势,数据传输分三步进行:由源发起拖放手势,由目标检测拖动手势,以及将源放到目标上。在该手势期间,为源节点和目标节点生成不同类型的事件。您还可以通过在拖放动作中显示图标来提供视觉线索。只要数据是可序列化的,拖放动作支持传输任何类型的数据。
下一章讨论如何在 JavaFX 中处理并发操作。