New to Java? We'll help you get started with our revised beginner's tutorial, or our free online textbook.
|
Get the latest Java books |
|
h t t p : / /w w w . j a v a c o f f e e b r e a k . c
o m /
|
Section 7.1
More About Graphics
IN THIS SECTION, we'll look at some additional aspects of graphics in Java. Most of the section deals with Images, which are pictures stored in files or in the computer's memory. But we'll also consider a few other techniques that can be used to draw better or more efficiently.
Images
To a computer, an image is just a set of numbers. The numbers specify the color of each pixel in the image. The numbers representing the image on the computer's screen are stored in a part of memory called a frame buffer. Many times each second, the computer's video card reads the data in the frame buffer and colors each pixel on the screen according to that data. Whenever the computer needs to make some change to the screen, it writes some new numbers to the frame buffer, and the change appears on the screen a fraction of a second later, the next time the screen is redrawn by the video card.
Since it's just a set of numbers, the data for an image doesn't have to be stored in a frame buffer. It can be stored elsewhere in the computer's memory. It can be stored in a file on the computer's hard disk. Just like any other data file, an image file can be downloaded over the Internet. Java includes standard classes and subroutines that can be used to copy image data from one part of memory to another and to get data from an image file and use it to display the image on the screen.
The standard class java.awt.Image is used to represent images. A particular object of type Image contains information about some particular image. There are actually two kinds of Image objects. One kind represents an image in an image data file. The second kind represents an image in the computer's memory. Either type of image can be displayed on the screen. The second kind of Image can also be modified while it is in memory. We'll look at this second kind of Image below.
Every image is coded as a set of numbers, but there are various ways in which the coding can be done. For images in files, there are two main coding schemes which are used in Java and on the Internet. One is used for GIF images, which are usually stored in files that have names ending in ".gif". The other is used for JPEG images, which are stored in files that have names ending in ".jpg" or ".jpeg". Both GIF and JPEG images are compressed. That is, redundancies in the data are exploited to reduce the number of numbers needed to represent the data. In general, the compression method used for GIF images works well for line drawings and other images with large patches of uniform color. JPEG compression generally works well for photographs.
The Applet class defines a method, getImage, that can be used for loading images stored in GIF and JPEG files (and possibly in other types of image files, depending on the version of Java). For example, suppose that the image of an ace of clubs, shown at the right, is contained in a file named "ace.gif". In the source code for an applet, if img is a variable of type Image, you could say
img = getImage( getCodeBase(), "ace.gif" );to create an Image object to represent the ace. The second parameter is the name of the file that contains the image. The first parameter specifies the directory that contains the image file. The value "getCodeBase()" specifies that the image file is in the code base directory for the applet. Assuming that the applet is in the default package, as usual, that just means that the image file is in the same directory as the compiled class file of the applet.
Once you have an object of type Image, however you obtain it, you can draw the image in any graphics context. Suppose that g is a graphics context, that is, an object belonging to the class Graphics, and suppose that img is a variable of type Image. Then the usual command for drawing the image, img, in the graphics context, g, is
g.drawImage(img, x, y, this);This command can be used in an instance method of an applet, canvas, or other component. The parameters x and y are integers that give the position of the top-left corner of the displayed image. The fourth parameter, "this", requires some explanation. It's there because of the funny way that Java works with images from image files. When you use getImage() to create an Image object from an image file, the file is not downloaded immediately. The Image object simply remembers where the file is. The file will be downloaded the first time you draw the image. However, when the image needs to be downloaded, the drawImage() method only initiates the downloading. It doesn't wait for the data to arrive. So, after drawImage() has finished executing, it's quite possible that the image has not actually been drawn! But then, when does it get drawn? That's where the fourth parameter to the drawImage() command comes in. The fourth parameter is something called an ImageObserver. After the image has been downloaded, the system will inform the ImageObserver that the image is available, and the ImageObserver will actually draw the image at that time. (For large images, it's even possible that the image will be drawn in several parts as it is downloaded.) Any Component object can act as an ImageObserver, including applets and canvases. In "g.drawImage(img, x, y, this);", the special variable this refers to the object whose source code you are writing. When you are drawing an image to the screen, you should almost always use "this" as the fourth parameter to drawImage().
There are a few useful variations of the drawImage() command. For example, it is possible to scale the image as it is drawn to a specified width and height. This is done with the command
g.drawImage(img, x, y, width, height, this);Another version makes it possible to draw just part of the image. In the command
g.drawImage(img, dest_x1, dest_y1, dest_x2, dest_y2, source_x1, source_y1, source_x2, source_y2, this);The integers source_x1, source_y1, source_x2, and source_y2 specify the top-left and bottom-right corners of a rectangular region in the source image. The integers dest_x1, dest_y1, dest_x2, and dest_y2 specify the corners of a region in the destination graphics context. The specified rectangle in the image is drawn, with scaling if necessary, to the specified rectangle in the graphics context. For an example in which this is useful, consider a card game that needs to display 52 different cards. Dealing with 52 image files can be cumbersome and inefficient, especially for downloading over the Internet. So, all the cards could be put into a single image:
Now, only one Image object is needed. Drawing one card means drawing a rectangular region from the image. This technique is used in the following version of the HighLow card game from Section 6.6:
In this applet, the cards are drawn by the following method. The variable, cardImages, is a variable of type Image that represents the image of 52 cards that is shown above. Each card is 40 by 60 pixels. These numbers are used, together with the suit and value of the card, to compute the corners of the source and destination rectangles for the drawImage() command:
void drawCard(Graphics g, Card card, int x, int y) { // Draws a card as a 40 by 60 rectangle with // upper left corner at (x,y). The card is drawn // in the graphics context g. If card is null, then // a face-down card is drawn. The cards are taken // from an Image object that loads the image from // the file smallcards.gif. if (card == null) { // Draw a face-down card g.setColor(Color.blue); g.fillRect(x,y,40,60); g.setColor(Color.white); g.drawRect(x+3,y+3,33,53); g.drawRect(x+4,y+4,31,51); } else { int row = 0; // Which of the four rows contains this card? switch (card.getSuit()) { case Card.CLUBS: row = 0; break; case Card.HEARTS: row = 1; break; case Card.SPADES: row = 2; break; case Card.DIAMONDS: row = 3; break; } int sx, sy; // Coords of upper left corner in the source image. sx = 40*(card.getValue() - 1); sy = 60*row; g.drawImage(cardImages, x, y, x+40, y+60, sx, sy, sx+40, sy+60, this); System.out.println(card.toString()); } } // end drawCard()The complete source code for this applet can be found in HighLowGUI2.java.
Double Buffering for Smooth Animation
In addition to images in image files, objects of type Image can be used to represent images stored in the computer's memory. What makes such images particularly useful is that it is possible to draw to an Image in the computer's memory. This drawing is not visible to the user. Later, however, the image can be copied very quickly to the screen. If this technique is used for repainting the screen, then behind the scenes, in memory, an old image is erased and a new one is drawn step-by-step. This takes some time. If all this drawing were done on screen, the user would see the image flicker. Instead, a complete new image replaces the old one on the screen almost instantaneously. The user doesn't see all the steps involved in redrawing. This technique can be used to do smooth, flicker-free animation and dragging.
I call an image in memory an off-screen canvas. The technique of drawing to an off-screen canvas and then quickly copying the canvas to the screen is called double buffering. The name comes from the term frame buffer, which refers to the region in memory that holds the image on the screen. (In fact, true double buffering uses two frame buffers. The video card can display either frame buffer on the screen and can switch instantaneously from one frame buffer to the other. One frame buffer is used as an off-screen canvas to prepare a new image for the screen. Then the video card is told to switch from one frame buffer to the other. No copying of memory is involved. Double-buffering as it is implemented in Java does require copying, which takes some time and is not entirely flicker-free.)
Here are two applets that are identical, except that one uses double buffering and one does not. You can drag the red and blue squares around the applets. For the applet on the left, you should notice an annoying flicker as you drag a square (although on very fast computers it might not be all that noticeable):
An off-screen Image can be created by calling the instance method createImage(), which is defined in the Component class. You can use this method in applets and canvases, for example. The createImage() method takes two parameters to specify the width and height of the image to be created. For example,
Image OSC = createImage(width, height);Drawing to an off-screen canvas is done in the same way as any other drawing in Java, by using a graphics context. The Image class defines an instance method getGraphics() that returns a Graphics object that can be used for drawing on the off-screen canvas. (This works only for off-screen canvases. If you try to do this with an Image from a file, an error will occur.) That is, if OSC is a variable of type Image that refers to an off-screen canvas, you can say
Graphics offscreenGraphics = OSC.getGraphics();Then, any drawing operations performed with the graphics context offscreenGraphics are applied to the off-screen canvas. For example, "offscreenGraphics.drawRect(10,10,50,100);" will draw a 50-by-100-pixel rectangle on the off-screen canvas. Once a picture has been drawn on the off-screen canvas, the picture can be copied into another graphics context, g, using the method g.drawImage(OSC).
When using an off-screen canvas to avoid flicker, it's convenient to manage the off-screen canvas in the update() method, which is called by the system when a component needs to be repainted. The update() method can create the off-screen canvas if it doesn't already exist, call the component's paint() method to draw to the off-screen canvas, and then copy the contents of the off-screen canvas to the screen. With just a little more work, we can even allow for the case where the size of the component can change. Here is what you would put in your applet or canvas class to make this work:
/* Some variable used for double-buffering */ Image OSC; // The off-screen canvas (created and used in update()). // The size of the OSC matches the size of the component. int widthOfOSC, heightOfOSC; // Current width and height of OSC. // These are checked against the size // of the component, to detect any change // in the component's size. If the size // has changed, a new OSC is created. public void update(Graphics g) { // To implement double-buffering, the update method calls paint to // draw the contents of the applet on an off-screen canvas. Then // the canvas is copied onto the screen. This method is responsible // for creating the off-screen canvas. It will make a new OSC if // the size of the applet changes. if (OSC == null || widthOfOSC != getSize().width || heightOfOSC != getSize().height) { // Create the OSC. // (Or make a new one if applet size has changed.) OSC = null; // (If OSC already exists, this frees up the memory.) OSC = createImage(getSize().width, getSize().height); widthOfOSC = getSize().width; heightOfOSC = getSize().height; } /* Set things up in the OSC the way things are usually set up for the paint method: Clear the OSC to the background color. Set the graphics context to use the component's drawing color and font. */ Graphics OSGr = OSC.getGraphics(); // Graphics context // for drawing to OSC. OSGr.setColor(getBackground()); OSGr.fillRect(0, 0, widthOfOSC, heightOfOSC); OSGr.setColor(getForeground()); OSGr.setFont(getFont()); paint(OSGr); // Draw component's contents to OSGr // instead of directly to g. OSGr.dispose(); // We're done with this graphics context. g.drawImage(OSC,0,0,this); // Copy OSC to screen. } // end update() public void paint(Graphics g) { // Draw the contents of the applet to the graphics context g, // just as they would be drawn on the screen. . . } // end paint()This is the technique used in the applet, shown above, that uses double buffering for smooth dragging. You can find the complete source code in the file DoubleBufferedDrag.java. An update method and a few extra instance variables are the only difference between the double-buffered version and the non-double-buffered version, NonDoubleBufferedDrag.java. The same technique can be used to do smooth animation, as we'll see in Section 5.
Double Buffering for Screen Repainting
Flicker was only one of the problems that we had with drawing in the previous chapter. Another problem was that, in many cases, we had no convenient way of remembering the contents of a component so that we could redraw the component when necessary. For example, in the paint applet in Section 6.6, the user's sketch will disappear if the applet is covered up and then uncovered. Double buffering can be used to solve this problem too. The idea is simple: Keep a copy of the drawing in an off-screen canvas. When the component needs to be redrawn, copy the off-screen canvas onto the screen. This method is used in the improved paint program at the end of this section.
When used in this way, the off-screen canvas should always contain a copy of the picture on the screen. The update() and paint() methods should do nothing but copy the off-screen canvas to the screen. This will refresh the picture when it is covered and uncovered. The actual drawing of the picture should take place elsewhere. (Occasionally, it makes sense to draw some extra stuff on the screen, on top of the image from the off-screen canvas. For example, a hilite or a shape that is being dragged might be treated in this way. These things are not permanently part of the image. The permanent image is safe in the off-screen canvas, and it can be used to restore the on-screen image when the hilite is removed or the shape is dragged to a different location.)
There are two approaches to keeping the image on the screen synchronized with the image in the off-screen canvas. In the first approach, in order to change the image, you make that change to the off-screen canvas and then call repaint() to copy the modified image to the screen. This is safe and easy, but not always efficient. The second approach is to make every change twice, once to the off-screen canvas and once to the screen. This keeps the two images the same, but it requires some care to make sure that exactly the same drawing is done in both.
In this application, it doesn't make sense to have the update() method create the canvas since the off-screen canvas is used outside the update() method. I suggest having a separate method to handle the creation of the off-screen canvas and its re-creation when the size of the component changes. This method should always be called before using the off-screen canvas in any way. Here is the basic code that a class needs in order to implement this:
/* Some variables used for double-buffering. */ Image OSC; // The off-screen canvas (created in setupOSC()). int widthOfOSC, heightOfOSC; // Current width and height of OSC. // These are checked against the size // of the component, to detect any change // in the component's size. If the size // has changed, a new OSC is created. // The picture in the off-screen canvas // is lost when that happens. void setupOSC() { // This method is responsible for creating the off-screen canvas. // It should always be called before using the OSC. It will make a // new OSC if the size of the applet changes. A new off-screen // canvas is filled with the background color of the component. if (OSC == null || widthOfOSC != getSize().width || heightOfOSC != getSize().height) { // Create the OSC, or make a new one // if component size has changed. OSC = null; // (If OSC already exists, this frees up the memory.) OSC = createImage(getSize().width, getSize().height); widthOfOSC = getSize().width; heightOfOSC = getSize().height; Graphics OSGr = OSC.getGraphics(); OSGr.setColor(getBackground()); OSGr.fillRect(0, 0, widthOfOSC, heightOfOSC); OSGr.dispose() } } public void update(Graphics g) { // Redefine update so it doesn't clear before calling paint(). paint(g); } public void paint(Graphics g) { // Just copy the off-screen canvas to the screen. setupOSC(); // Ensure that OSC exists first!! g.drawImage(OSC, 0, 0, this); }Note that the contents of the off-screen canvas are lost if the size changes. If this is a problem, you can consider copying the contents of the old off-screen canvas to the new one before discarding the old canvas. You can do this with drawImage(), and you can even scale the image to fit the new size if you want. However, the results of scaling are not always attractive.
FontMetrics
In the rest of this section, we turn from Images to look briefly at a few other aspects of Java graphics.
Often, when drawing a string, it's important to know how big the image of the string will be. You need this information if you want to center a string on an applet. Or if you want to know how much space to leave between two lines of text, when you draw them one above the other. Or if the user is typing the string and you want to position a cursor at the end of the string. In Java, questions about the size of a string are answered by an object belonging to the standard class java.awt.FontMetrics.
There are several lengths associated with any given font. Some of them are shown in this illustration:
The red lines in the illustration are the baselines of the two lines of text. The suggested distance between two baselines, for single-spaced text, is known as the lineheight of the font. The ascent is the distance that tall characters can rise above the baselines, and the descent is the distance that tails like the one on the letter g can descend below the baseline. The ascent and descent do not add up to the lineheight, because there should be some extra space between the tops of characters in one line and the tails of characters on the line above. The extra space is called leading. All these quantities can be determined by calling instance methods in a FontMetrics object. There are also methods for determining the width of a character and the width of a string.
If F is a font and g is a graphics context, you can get a FontMetrics object for the font F by calling g.getFontMetrics(F). If fm is a variable that refers to the FontMetrics object, then the ascent, descent, leading, and lineheight of the font can be obtained by calling fm.getAscent(), fm.getDescent(), fm.getLeading(), and fm.getHeight(). If ch is a character, then fm.charWidth(ch) is the width of the character when it is drawn in that font. If str is a string, then fm.stringWidth(str) is the width of the string. For example, here is a paint() method that shows the message "Hello World" in the exact center of the component:
public void paint(Graphics g) { int width, height; // Width and height of the string. int x, y; // Starting point of baseline of string. Font F = g.getFont(); // What font will g draw in? FontMetrics fm = g.getFontMetrics(F); width = fm.stringWidth("Hello World"); height = fm.getAscent(); // Note: There are no tails on // any of the chars in the string! x = getSize().width / 2 - width / 2; // Go to center and back up // half the width of the // string. y = getSize().height / 2 + height / 2; // Go to center, then move // down half the height of // the string, g.drawString("Hello World", x, y); }
Drawing with XORMode
Ordinarily, when shapes or text are drawn in a graphics context, g, the colors of the affected pixels are changed to the current drawing color of g, as specified by g.setColor(). In Java, this type of drawing is called paint mode, and it is not the only possibility. There is another mode of drawing called XOR mode, in which the effect on the color of pixels is not so straightforward. Drawing in XOR mode has an interesting and useful property: If you perform exactly the same drawing operation twice in a row, the second operation reverses the effect of the first, leaving the image in its original state. Unfortunately, you can't be sure what colors will be used when you draw in XOR mode.
XOR mode can be used, for example, to implement a "rubber band cursor." A rubber band cursor is commonly used to draw straight lines. When the user clicks and drags the mouse, a moving line stretches between the starting point of the drag and the current mouse location. When the user releases the mouse, the line becomes a permanent part of the image. While the mouse is being dragged, the line is drawn in XOR mode. When the mouse moves, the line is first redrawn in its previous position. In XOR mode, this second drawing operation erases the first. Then, the line is drawn in its new position. When the user releases the mouse, the line is erased one more time and is then drawn permanently using paint mode. The same idea can be used for other figures besides lines. However, it doesn't work very well for filled shapes because of the weird color effects. (Of course, maybe you like weird color effects.) A simple solution to the problem with filled shapes is to draw only the outline of the shape when dragging.
Here is a little applet that illustrates XOR mode. Draw straight red lines by clicking and dragging. Draw blue filled rectangles by right-clicking and dragging (or, on the Mac, Command-clicking). Shift-click on the applet to clear it. Check out the colors when you draw one rectangle on top of another:
If g is a graphics context, then you can use the command g.setXORMode(xorColor) to start using XOR mode. The xorColor parameter is a Color that will (on some platforms) be used as follows: If you draw in XOR mode over pixels whose color is the specified xorColor, then those pixels will be changed to the current drawing color of the graphics context. That is, drawing over the xorColor in XOR mode is the same as drawing over this color in the regular paint mode. In almost all cases, you want to use the background color as the xorColor, so you should say g.setXORMode(getBackground()). You can switch from XOR mode back to regular paint mode with the command g.setPaintMode().
There is one other point of interest in the above applet. To draw a rectangle in Java, you need to know the coordinates of the upper left corner, the width, and the height. However, when a rectangle is drawn in this applet, the available data consists of two corners of the rectangle: the starting position of the mouse and its current position. From these two corners, the left, top, width, and height of the rectangle have to be computed. This can be done as follows:
void drawRectUsingCorners(Graphics g, int x1, int y1, int x2, int y2) { // Draw a rectangle with corners at (x1,y1) and (x2,y2). int x,y; // Coordinates of the top-left corner. int w,h; // Width and height of rectangle. if (x1 < x2) { // x1 is the left edge x = x1; w = x2 - x1; } else { // x2 is the left edge x = x2; w = x1 - x2; } if (y1 < y2) { // y1 is the top edge y = y1; h = y2 - y1; } else { // y2 is the top edge y = y2; h = y1 - y2; } g.drawRect(x, y, w, h); // Draw the rect. }The source code for the above applet is in the file RubberBand.java.
A Better Paint Program
The techniques covered in this section can be used to improve the simple painting program from Section 6.6. The new version uses an off-screen canvas to save a copy of the user's work. As before, the user can draw a free-hand sketch. However, in this version, the user can also choose to draw several shapes by selecting from the pop-up menu in the upper right. The shapes are drawn using rubber band cursors in XOR mode. Try it out! Check that when you cover up the applet with another window, your drawing is still there when you uncover it.
The source code for this improved paint applet is in the file SimplePaint2.java. It uses an off-screen canvas pretty much in the way described above. The paint() method for the Canvas object simply copies the off-screen canvas to the screen. When the user begins a drag operation, two graphics contexts are obtained, one for drawing on the screen and one for drawing to the off-screen canvas. If the user is sketching a curve, every line segment in the curve is drawn to both the screen and to the off-screen canvas. This keeps the off-screen picture in sync with the picture on the screen. When the user is drawing a shape, the rubber band cursor is treated somewhat differently. The rubber band cursor is not a permanent part of the image, so there is no need to draw it to the off-screen canvas. It is drawn only on the screen. When the drag operation ends, the final shape is drawn to both the off-screen canvas and to the screen. There are lots of other details to attend to. I encourage you to read the source code and see how its done.
[ Next Section | Previous Chapter | Chapter Index | Main Index ]