package magic.ui.screen.wip.cardflow; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.Transparency; import java.awt.event.ActionEvent; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.KeyEvent; import java.awt.event.MouseWheelEvent; import java.awt.event.MouseWheelListener; import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.List; import javax.swing.AbstractAction; import javax.swing.JPanel; import javax.swing.KeyStroke; import magic.ui.helpers.ImageHelper; import org.pushingpixels.trident.Timeline; import org.pushingpixels.trident.callback.TimelineCallback; @SuppressWarnings("serial") class CardFlowPanel extends JPanel implements TimelineCallback { // magiccards.info = (312, 445), mtgimages.com = (480, 680) public static final Dimension MAX_IMAGE_SIZE = new Dimension(312, 445); private static final int SLOT_OVERLAP = 140; private List<BufferedImage> images = new ArrayList<>(); private int activeImageIndex = 0; private final CardFlowTimeline timeline; private BufferedImage contentImage; private Dimension currentSize = new Dimension(); private float timelinePulse = 1.0f; private Color imageBackgroundColor = getBackground(); private final List<Rectangle> slots = new ArrayList<>(); private Rectangle activeSlot; private Dimension selectedImageSize; private List<ICardFlowListener> listeners = new ArrayList<>(); private enum FlowDirection { LEFT, RIGHT } private FlowDirection flowDirection = FlowDirection.RIGHT; CardFlowPanel() { setRedrawOnResize(); setScrollUsingMouseWheel(); setScrollKeys(); timeline = new CardFlowTimeline(this); } public void setImages(final List<BufferedImage> images) { this.images = images; if (images.size() > 0) { activeImageIndex = images.size() / 2; assert activeImageIndex >= 0 && activeImageIndex < images.size(); } repaint(); notifyListeners(); } private void drawCards(final Graphics2D g2d) { // identify index of active slot (normally middle-most). final int activeSlotIndex = slots.indexOf(activeSlot); drawLeadingImages(g2d, activeSlotIndex); drawTrailingImages(g2d, activeSlotIndex); doAnimateSwapTopCard(g2d, activeSlotIndex); } private void drawSlots(final Graphics2D g2d) { // draw all available visible slots. g2d.setColor(Color.GRAY); g2d.setStroke(new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10, new float[]{2, 2}, 0)); for (Rectangle r : slots) { g2d.drawRect(r.x, r.y, r.width - 1, r.height - 1); } } private void drawLeadingImages(final Graphics2D g2d, final int activeSlotIndex) { if (activeImageIndex > 0) { final int startImage = Math.max(activeImageIndex - activeSlotIndex, 0); final int endImage = activeImageIndex - (flowDirection == FlowDirection.LEFT ? 1 : 0); for (int i = startImage; i < endImage; i++) { final int imageSlot = activeSlotIndex - (activeImageIndex - i); final Rectangle startRect = flowDirection == FlowDirection.RIGHT ? imageSlot > 0 ? slots.get(imageSlot - 1) : slots.get(imageSlot) : slots.get(imageSlot + 1); final Rectangle endRect = slots.get(imageSlot); final Rectangle drawRect = new Rectangle( startRect.x - (int) ((startRect.x - endRect.x) * timelinePulse), 0, startRect.width + (int) ((endRect.width - startRect.width) * timelinePulse), startRect.height + (int) ((endRect.height - startRect.height) * timelinePulse) ); final BufferedImage image = images.get(i); g2d.drawImage(image, drawRect.x, drawRect.y, drawRect.width, drawRect.height, null); } } } private void drawTrailingImages(final Graphics2D g2d, final int activeSlotIndex) { if (activeImageIndex < images.size()) { final int startImage = Math.min(slots.size() - activeSlotIndex, images.size() - activeImageIndex) + activeImageIndex - 1; final int endImage = activeImageIndex + (flowDirection == FlowDirection.RIGHT ? 1 : 0); for (int i = startImage; i > endImage; i--) { final int imageSlot = (activeSlotIndex - activeImageIndex) + i; final Rectangle startRect = flowDirection == FlowDirection.RIGHT ? slots.get(imageSlot - 1) : imageSlot < slots.size() - 1 ? slots.get(imageSlot + 1) : slots.get(imageSlot); final Rectangle endRect = slots.get(imageSlot); final Rectangle drawRect = new Rectangle( startRect.x - (int) ((startRect.x - endRect.x) * timelinePulse), 0, startRect.width + (int) ((endRect.width - startRect.width) * timelinePulse), startRect.height + (int) ((endRect.height - startRect.height) * timelinePulse) ); final BufferedImage image = images.get(i); g2d.drawImage(image, drawRect.x, drawRect.y, drawRect.width, drawRect.height, null); } } } private void drawActiveImage(final Graphics2D g2d, final int activeSlotIndex) { final Rectangle startRect = flowDirection == FlowDirection.RIGHT ? slots.get(activeSlotIndex - 1) : slots.get(activeSlotIndex + 1); final Rectangle endRect = slots.get(activeSlotIndex); final int adjX = (int)((startRect.x - endRect.x) * timelinePulse); final int adjX2 = (int)(getBellShapedFunctionB(timelinePulse)); final Rectangle imageRect = new Rectangle( (startRect.x - adjX) + (flowDirection == FlowDirection.LEFT ? adjX2 : -adjX2), 0, startRect.width + (int) ((endRect.width - startRect.width) * timelinePulse), startRect.height + (int) ((endRect.height - startRect.height) * timelinePulse) ); g2d.drawImage(images.get(activeImageIndex), imageRect.x, imageRect.y, imageRect.width, imageRect.height, null); } private void drawActiveImageMinusOne(final Graphics2D g2d, final int activeSlotIndex) { if (activeImageIndex == 0) { return; } final Rectangle startRect = slots.get(activeSlotIndex); final Rectangle endRect = flowDirection == FlowDirection.RIGHT ? slots.get(activeSlotIndex + 1) : slots.get(activeSlotIndex - 1); final int adjX = (int)((startRect.x - endRect.x) * timelinePulse); final int adjX2 = (int)(getBellShapedFunctionB(timelinePulse) * SLOT_OVERLAP); final Rectangle imageRect = new Rectangle( (startRect.x - adjX) - adjX2, 0, startRect.width + (int) ((endRect.width - startRect.width) * timelinePulse), startRect.height + (int) ((endRect.height - startRect.height) * timelinePulse) ); g2d.drawImage(images.get(activeImageIndex - 1), imageRect.x, imageRect.y, imageRect.width, imageRect.height, null); } private void drawActiveImagePlusOne(final Graphics2D g2d, final int activeSlotIndex) { if (activeImageIndex == images.size() - 1) { return; } final Rectangle startRect = slots.get(activeSlotIndex); final Rectangle endRect = slots.get(activeSlotIndex + 1); final int adjX = (int)((endRect.x - startRect.x) * timelinePulse); final int adjX2 = (int)(getBellShapedFunctionB(timelinePulse) * SLOT_OVERLAP); final Rectangle imageRect = new Rectangle( (startRect.x + adjX) + adjX2, 0, startRect.width + (int) ((endRect.width - startRect.width) * timelinePulse), startRect.height + (int) ((endRect.height - startRect.height) * timelinePulse) ); g2d.drawImage(images.get(activeImageIndex + 1), imageRect.x, imageRect.y, imageRect.width, imageRect.height, null); } private void drawDeactivatedImage(final Graphics2D g2d, final int activeSlotIndex) { if (flowDirection == FlowDirection.LEFT) { drawActiveImageMinusOne(g2d, activeSlotIndex); } else { drawActiveImagePlusOne(g2d, activeSlotIndex); } } private void doAnimateSwapTopCard(final Graphics2D g2d, final int activeSlotIndex) { if (timelinePulse < 0.51f) { drawActiveImage(g2d, activeSlotIndex); drawDeactivatedImage(g2d, activeSlotIndex); } else { drawDeactivatedImage(g2d, activeSlotIndex); drawActiveImage(g2d, activeSlotIndex); } } private void calculateImageSlots() { slots.clear(); // Canvas dimensions. final int canvasWidth = getWidth(); final int canvasHeight = getHeight(); final Dimension maxImageSize = MAX_IMAGE_SIZE; final double imageAspectRatio = maxImageSize.width / (double) maxImageSize.height; selectedImageSize = new Dimension((int) (canvasHeight * imageAspectRatio), canvasHeight); int x = canvasWidth / 2 - selectedImageSize.width / 2; int xLeft = x; int xRight = x; double scale = 1.0; int w = selectedImageSize.width; int h = selectedImageSize.height; int gap = -SLOT_OVERLAP; activeSlot = new Rectangle(x, 0, w, h); slots.add(activeSlot); boolean exitLoop; do { xRight = xRight + w + gap; scale -= 0.1; w = (int) (selectedImageSize.width * scale); h = (int) (selectedImageSize.height * scale); final int newLeftX = xLeft - gap - w; exitLoop = xLeft < 0 || newLeftX >= xLeft; xLeft = newLeftX; slots.add(0, new Rectangle(xLeft, 0, w, h)); slots.add(new Rectangle(xRight, 0, w, h)); } while (xLeft+w >= 0 && w > 0 && !exitLoop); // System.out.println("Total number of slots = " + slots.size() + " (visible=" + (slots.size() - 2) + ")"); } private void setRedrawOnResize() { addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { if (getSize().equals(currentSize) == false) { currentSize = new Dimension(getSize()); calculateImageSlots(); drawContentImage(); repaint(); } else { System.out.println("SAME SIZE!"); } } }); } private void setScrollUsingMouseWheel() { addMouseWheelListener(new MouseWheelListener() { @Override public void mouseWheelMoved(MouseWheelEvent ev) { if (ev.getPreciseWheelRotation() > 0) { // rotate wheel towards you. doClickRight(); } else if (ev.getWheelRotation() < 0) { // rotate wheel away from you. doClickLeft(); } } }); } private void setScrollKeys() { // Right arrow key getInputMap(JPanel.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "ScrollRight"); getActionMap().put("ScrollRight", new AbstractAction() { @Override public void actionPerformed(final ActionEvent e) { doClickRight(); } }); // Left arrow key getInputMap(JPanel.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "ScrollLeft"); getActionMap().put("ScrollLeft", new AbstractAction() { @Override public void actionPerformed(final ActionEvent e) { doClickLeft(); } }); } private void drawContentImage() { contentImage = ImageHelper.getCompatibleBufferedImage( this.getWidth(), selectedImageSize.height, Transparency.OPAQUE); final Graphics2D g2d = contentImage.createGraphics(); g2d.setColor(imageBackgroundColor); g2d.fillRect(0, 0, contentImage.getWidth(), contentImage.getHeight()); if (images.size() > 0) { drawCards(g2d); } g2d.dispose(); } @Override public void onTimelinePulse(float arg0, float arg1) { timelinePulse = arg1; drawContentImage(); repaint(); } @Override public void onTimelineStateChanged(Timeline.TimelineState arg0, Timeline.TimelineState arg1, float arg2, float arg3) { if (arg0 == Timeline.TimelineState.DONE && arg1 == Timeline.TimelineState.IDLE) { onTimelinePulse(0, 1.0f); notifyListeners(); } } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); if (contentImage != null) { final Rectangle clipRect = new Rectangle( 0, this.getHeight() / 2 - contentImage.getHeight() / 2, contentImage.getWidth(), contentImage.getHeight()); g.setClip(clipRect); g.drawImage(contentImage, clipRect.x, clipRect.y, this); } } private void notifyListeners() { for (ICardFlowListener aListener : listeners) { aListener.setNewActiveImage(activeImageIndex); } } public void addListener(ICardFlowListener aListener) { listeners.add(aListener); } public int getImagesCount() { return images.size(); } public void doClickLeft() { if (timeline.getState() == Timeline.TimelineState.IDLE) { if (activeImageIndex > 0) { flowDirection = FlowDirection.RIGHT; activeImageIndex = activeImageIndex - 1; timelinePulse = 0.0f; timeline.play(); } } } public void doClickRight() { if (timeline.getState() == Timeline.TimelineState.IDLE) { if (activeImageIndex < images.size() - 1) { flowDirection = FlowDirection.LEFT; activeImageIndex = activeImageIndex + 1; timelinePulse = 0.0f; timeline.play(); } } } /** * Given a x = 0.0 to 1.0, returns f(0) = 0, f(0.5) = 1, f(1.0) = 0. * * see https://stackoverflow.com/questions/13097005/easing-functions-for-bell-curves. */ private double getBellShapedFunctionA(final float x) { return (Math.sin(2 * Math.PI * (x - 0.25d)) + 1) / 2.0d; } /** * Given a x = 0.0 to 1.0, returns f(0) = 0, f(0.5) = 1, f(1.0) = 0. * * see https://stackoverflow.com/questions/13097005/easing-functions-for-bell-curves. */ private double getBellShapedFunctionB(final float x) { final double sigma = 1.5d; return (Math.pow(4, sigma) * Math.pow(x, sigma - 1) * Math.pow(1 - x, sigma - 1)) / 4d; } @Override public void setOpaque(boolean isOpaque) { // If not opaque, animation is much less smoother with frequent flickering. super.setOpaque(true); } @Override public Dimension getPreferredSize() { return new Dimension(super.getPreferredSize().width, MAX_IMAGE_SIZE.height); } @Override public Dimension getMinimumSize() { return new Dimension(super.getMinimumSize().width, MAX_IMAGE_SIZE.height); } @Override public Dimension getMaximumSize() { return new Dimension(super.getMaximumSize().width, MAX_IMAGE_SIZE.height); } @Override public void setBackground(Color bg) { imageBackgroundColor = bg; // Ideally panel height should be at preferred size to match height of // card flow image but if not then delineate image and this JPanel (on // which the image is painted) with a darker background color. super.setBackground(bg.darker()); } }