I'm using double-buffered graphics in my JLayer subclass to implement a simple swipe animation in a Java Swing application. It works fine on the older displays, but when I run it on a Retina display, the screen loses the doubled-resolution when the animation starts, and gets it back when it ends. I'm not sure how to maintain the higher resolution during the animation.
My animate method originally looked like this:
private void animate() {
Timer timer = new Timer(frameMillis, null);
final ActionListener actionListener = (evt) -> { /* omitted for brevity */ };
timer.addActionListener(actionListener);
int imageType = BufferedImage.TYPE_INT_ARGB;
upcomingScreen = new BufferedImage(liveComponent.getWidth(), liveComponent.getHeight(), imageType);
Graphics2D graphics2D = (Graphics2D) upcomingScreen.getGraphics();
liveComponent.paint(graphics2D); // liveComponent is a JComponent
graphics2D.dispose();
timer.start();
}
I tried doubling the image size, but that didn't help.
upcomingScreen = new BufferedImage(liveComponent.getWidth()*2, liveComponent.getHeight()*2, imageType);
To reflect these changes, I changed my drawing code in LayerUI by doubling xLimit
, width
, height
:
public void paint(final Graphics g, final JComponent c) {
if (isAnimating) {
int xLimit = (c.getWidth()*2 * frame) / maxFrames;
int width = c.getWidth()*2;
int height = c.getHeight()*2;
g.drawImage(uScreen, 0, 0, xLimit, height, 0, 0, xLimit, height, c);
g.drawImage(pScreen, xLimit, 0, width, height, xLimit, 0, width, height, c);
} else {
super.paint(g, c);
}
}
This doesn't help. It draws the same with or without this last change, which makes no sense.
Here is a class that illustrates the problem:
/**
* <p>Created by IntelliJ IDEA.
* <p>Date: 5/2/20
* <p>Time: 10:25 AM
*
* @author Miguel Mu\u00f1oz
*/
@SuppressWarnings({"HardcodedLineSeparator", "StringConcatenation", "HardCodedStringLiteral", "DuplicatedCode"})
public final class SwipeViewTest extends JPanel {
public static final String text1 = "Demo of Swipe View.\n\nThe swipe button will toggle between two pages of text. It has a built-in " +
"special effect, which is a swipe. When you hit the swipe button, it should flip between two pages of text. This worked fine on " +
"the older displays, but for some reason, on a Retina display, the text briefly switches to low resolution as the swipe proceeds, " +
"then switches back once it has finished. This code is written for retina displays. I don't know if it will work for the older, " +
"low resolution displays.\n\nYou can watch it swipe by hitting the space bar or by clicking the swipe button.";
public static final String text2 = "Demo of Swipe View.\n\nThis is the second page of the swipe-text demo. The change in resolution is " +
"most easily noticed when watching the line at the top, which doesn't change as the swipe is performed.";
private final SwipeView<TestView> swipeView;
private final TestView testView;
public static void main(String[] args) {
JFrame frame = new JFrame("SwipeView demo");
SwipeViewTest comp = new SwipeViewTest();
comp.install();
frame.add(comp);
frame.setLocationByPlatform(true);
frame.pack();
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
frame.setVisible(true);
}
private boolean page1 = true;
private SwipeViewTest() {
super(new BorderLayout());
testView = new TestView();
swipeView = SwipeView.wrap(testView, 1000);
add(BorderLayout.CENTER, swipeView.getLayer());
}
private void install() {
JButton jButton = new JButton("Swipe");
jButton.addActionListener(this::doSwipe);
add(jButton, BorderLayout.PAGE_END);
AncestorListener ancestorListener = new AncestorListener() {
@Override
public void ancestorAdded(final AncestorEvent event) {
JComponent button = event.getComponent();
button.requestFocus();
button.removeAncestorListener(this); // execute only once.
}
@Override public void ancestorRemoved(final AncestorEvent event) { }
@Override public void ancestorMoved(final AncestorEvent event) { }
};
jButton.addAncestorListener(ancestorListener);
}
private void doSwipe(ActionEvent ignored) {
swipeView.swipeLeft(this::flipPage);
}
private void flipPage() {
page1 = !page1;
if (page1) {
testView.setText(text1);
} else {
testView.setText(text2);
}
}
private static class TestView extends JPanel {
private final JTextArea textArea;
TestView() {
super(new BorderLayout());
textArea = new JTextArea(20, 40);
JScrollPane scrollPane = new JScrollPane(textArea, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
textArea.setLineWrap(true);
textArea.setWrapStyleWord(true);
textArea.setEditable(false);
textArea.setText(text1);
add(scrollPane, BorderLayout.CENTER);
}
private void setText(String text) {
textArea.setText(text);
}
}
/**
* SwipeView adds a swipe special effect to a Component. This draws a swipe-right or swipe-left effect on a chosen
* action. It also optionally supports a repeated action when the mouse is held down.
* <p>
* This class is very specific right now, but I hope to generalize it for other special effects later.
* <p>Created by IntelliJ IDEA.
* <p>Date: 4/4/18
* <p>Time: 12:38 AM
*
* @author Miguel Mu\u00f1oz
*/
@SuppressWarnings("MagicNumber")
public static final class SwipeView<C extends JComponent> extends LayerUI<C> {
public static <J extends JComponent> SwipeView<J> wrap(J view, int durationMillis) {
JLayer<J> jLayer = new JLayer<>(view);
final SwipeView<J> ui = new SwipeView<>(view, jLayer, durationMillis);
jLayer.setUI(ui);
return ui;
}
private final C liveComponent;
private Image priorScreen = null;
private Image upcomingScreen = null;
private final JLayer<C> layer;
private boolean isAnimating = false;
private SwipeDirection swipeDirection = SwipeDirection.SWIPE_RIGHT;
private final int maxFrames;
// Calculated:
@SuppressWarnings("FieldCanBeLocal")
private final int frameMillis;
private int frame = 0;
private final long startTime = System.currentTimeMillis();
private SwipeView(C view, JLayer<C> theLayer, int animationDurationMillis) {
super();
liveComponent = view;
layer = theLayer;
maxFrames = (30 * animationDurationMillis) / 1000;
frameMillis = animationDurationMillis / maxFrames;
}
public JLayer<C> getLayer() { return layer; }
/**
* Perform the specified operation with a swipe-right special effect. This is often used in an ActionListener:
* <pre>
* first.addActionListener((e) -> swipeView.swipeRight(recordModel::goFirst));
* </pre>
* Here, the Action listener will perform a Swipe-right after executing the goFirst() method of recordModel.
*
* @param operation The operation
*/
@SuppressWarnings("WeakerAccess")
public void swipeRight(Runnable operation) {
swipe(operation, SwipeDirection.SWIPE_RIGHT);
}
/**
* Perform the specified operation with a swipe-left special effect. This is often used in an ActionListener:
* <pre>
* first.addActionListener((e) -> swipeView.swipeLeft(recordModel::goFirst));
* </pre>
* Here, the Action listener will perform a Swipe-Left after executing the goFirst() method of recordModel.
*
* @param operation The operation
*/
@SuppressWarnings("WeakerAccess")
public void swipeLeft(Runnable operation) {
swipe(operation, SwipeDirection.SWIPE_LEFT);
}
private void swipe(Runnable operation, SwipeDirection swipeDirection) {
prepareToAnimate(swipeDirection);
operation.run();
animate();
}
// @SuppressWarnings({"HardCodedStringLiteral", "HardcodedFileSeparator"})
@Override
public void paint(final Graphics g, final JComponent c) {
if (isAnimating) {
int xLimit = (c.getWidth() * 2 * frame) / maxFrames;
if (swipeDirection == SwipeDirection.SWIPE_LEFT) {
xLimit = (c.getWidth() * 2) - xLimit;
}
int width = c.getWidth() * 2;
int height = c.getHeight() * 2;
// //noinspection UseOfSystemOutOrSystemErr
// System.out.printf("Dimensions: Frame: %d/%d (at %d) xLimit: %4d (%4d x %4d) (from %4d x %4d) Animating: %b%n",
// frame, maxFrames, System.currentTimeMillis() - startTime, xLimit, width, height, c.getWidth(), c.getHeight(), isAnimating);
assert upcomingScreen != null;
assert priorScreen != null;
Image pScreen = Objects.requireNonNull(priorScreen);
Image uScreen = Objects.requireNonNull(upcomingScreen);
if (swipeDirection == SwipeDirection.SWIPE_RIGHT) {
g.drawImage(uScreen, 0, 0, xLimit, height, 0, 0, xLimit, height, c);
g.drawImage(pScreen, xLimit, 0, width, height, xLimit, 0, width, height, c);
} else {
g.drawImage(uScreen, xLimit, 0, width, height, xLimit, 0, width, height, c);
g.drawImage(pScreen, 0, 0, xLimit, height, 0, 0, xLimit, height, c);
}
} else {
super.paint(g, c);
}
}
private void prepareToAnimate(SwipeDirection swipeDirection) {
this.swipeDirection = swipeDirection;
isAnimating = true;
frame = 0;
// Save current state
priorScreen = new BufferedImage(liveComponent.getWidth() * 2, liveComponent.getHeight() * 2, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics2D = (Graphics2D) priorScreen.getGraphics();
liveComponent.paint(graphics2D);
graphics2D.dispose();
}
private void animate() {
Timer timer = new Timer(frameMillis, null);
final ActionListener actionListener = (evt) -> {
frame++;
layer.repaint();
if (frame == maxFrames) {
frame = 0;
isAnimating = false;
timer.stop(); // Investigate: Am I leaking timers?
}
};
timer.addActionListener(actionListener);
upcomingScreen = new BufferedImage(liveComponent.getWidth() * 2, liveComponent.getHeight() * 2, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics2D = (Graphics2D) upcomingScreen.getGraphics();
liveComponent.paint(graphics2D);
graphics2D.dispose();
timer.start();
}
}
public static enum SwipeDirection {
@SuppressWarnings("JavaDoc") SWIPE_RIGHT,
@SuppressWarnings("JavaDoc") SWIPE_LEFT
}
}
It turns out I needed to change the way I animated the frame to account for the doubling of the scale.
First, I needed to detect the scale. I added this code, which requires Java 9 or greater to work correctly. (It compiles under java 8, but fails to execute correctly, always returning 1 for any screen.)
private static final int SCALE = calculateScaleForDefaultScreen();
private static int calculateScaleForDefaultScreen() {
// scale will be 2.0 for a Retina screen, and 1.0 for an older screen
double scale = GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice()
.getDefaultConfiguration()
.getDefaultTransform()
.getScaleX(); // Requires Java 9+ to work. Compiles under Java 8 but always returns 1.0.
//noinspection NumericCastThatLosesPrecision
return (int) Math.round(scale);
}
When I prepared my two off-screen graphics, I needed to do so at twice the scale:
Graphics2D graphics2D = (Graphics2D) priorScreen.getGraphics();
graphics2D.scale(SCALE, SCALE);
liveComponent.paint(graphics2D); // paint the current state of liveComponent into the image
graphics2D.dispose();
And…
Graphics2D graphics2D = (Graphics2D) upcomingScreen.getGraphics();
graphics2D.scale(SCALE, SCALE);
liveComponent.paint(graphics2D); // paint the upcoming state of liveComponent into the image
graphics2D.dispose();
Then, when I did my animation, I needed to include the SCALE in the drawing.
if (swipeDirection == SwipeDirection.SWIPE_RIGHT) {
g.drawImage(uScreen, 0, 0, xLimit, height, 0, 0, xLimit*SCALE, height*SCALE, c);
g.drawImage(pScreen, xLimit, 0, width, height, xLimit*SCALE, 0, width*SCALE, height*SCALE, c);
} else {
g.drawImage(uScreen, xLimit, 0, width, height, xLimit*SCALE, 0, width*SCALE, height*SCALE, c);
g.drawImage(pScreen, 0, 0, xLimit, height, 0, 0, xLimit*SCALE, height*SCALE, c);
}
There are several other places where I multiplied widths and heights by 2. I changed those to SCALE as well.
I wish there were a more elegant solution, but this works.