/*
* Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.components;
import com.codename1.ui.Component;
import com.codename1.ui.Display;
import com.codename1.ui.Form;
import com.codename1.ui.Graphics;
import com.codename1.ui.Image;
import com.codename1.ui.animations.Animation;
import com.codename1.ui.animations.Motion;
import com.codename1.ui.events.DataChangedListener;
import com.codename1.ui.events.SelectionListener;
import com.codename1.ui.geom.Dimension;
import com.codename1.ui.list.DefaultListModel;
import com.codename1.ui.list.ListModel;
import com.codename1.ui.plaf.Style;
/**
* <p>ImageViewer allows zooming/panning an image and potentially flicking between multiple images
* within a list of images. <br>
* E.g. the trivial usage works like this:</p>
* <script src="https://gist.github.com/codenameone/350a58254aa8b6f9f661.js"></script>
* <img src="https://www.codenameone.com/img/developer-guide/components-imageviewer.png" alt="Simple image viewer zoomed out" />
* <img src="https://www.codenameone.com/img/developer-guide/components-imageviewer-zoomed-in.png" alt="Simple image viewer zoomed in" />
* <p>
* You can simulate pinch to zoom on the simulator by dragging the right button away from the top left corner to
* zoom in and towards the top left corner to zoom out. On Mac touchpads you can drag two fingers to achieve that.
* </p>
* <p>
* A more elaborate usage includes flicking between multiple images e.g.:
* </p>
* <script src="https://gist.github.com/codenameone/2001562d621473fd42c5.js"></script>
* <img src="https://www.codenameone.com/img/developer-guide/components-imageviewer-multi.png" alt="Image viewer with multiple elements" />
*
* <p>
* You can even download image URL's dynamically into the {@code ImageViewer} thanks to the usage of the
* {@link com.codename1.ui.list.ListModel}. E.g. in this model book cover images are downloaded dynamically:
* </p>
* <script src="https://gist.github.com/codenameone/305c3f5426b0e2e80833.js"></script>
* <img src="https://www.codenameone.com/img/developer-guide/components-imageviewer-dynamic.png" alt="Image viewer with dynamic URL fetching model" />
*
* @author Shai Almog
*/
public class ImageViewer extends Component {
private float zoom = 1;
private float currentZoom = 1;
private static final int MIN_ZOOM = 1;
private static final int MAX_ZOOM = 10;
private Image image;
private int imageX, imageY, imageDrawWidth, imageDrawHeight;
private float panPositionX = 0.5f;
private float panPositionY = 0.5f;
private int pressX, pressY;
private ListModel<Image> swipeableImages;
private DataChangedListener listListener;
private Image swipePlaceholder;
private float swipeThreshold = 0.4f;
private int imageInitialPosition = IMAGE_FIT;
/**
* Indicates the initial position of the image in the viewer to FIT to the
* component size
*/
public final static int IMAGE_FIT = 0;
/**
* Indicates the initial position of the image in the viewer to FILL the
* component size.
* Notice this type might drop edges of the images in order to stretch the image
* to the full size of the Component.
*/
public final static int IMAGE_FILL = 1;
// return values from image aspect calc
private int prefX, prefY, prefW, prefH;
private boolean eagerLock = true;
private boolean selectLock;
private boolean cycleLeft = true;
private boolean cycleRight = true;
/**
* Default constructor
*/
public ImageViewer() {
setFocusable(true);
setUIID("ImageViewer");
}
/**
* {@inheritDoc}
*/
protected void resetFocusable() {
setFocusable(true);
}
/**
* {@inheritDoc}
*/
public String[] getPropertyNames() {
return new String[] {"eagerLock", "image", "imageList", "swipePlaceholder"};
}
/**
* {@inheritDoc}
*/
protected boolean shouldBlockSideSwipe() {
return true;
}
/**
* {@inheritDoc}
*/
public Class[] getPropertyTypes() {
return new Class[] {Boolean.class, Image.class,
com.codename1.impl.CodenameOneImplementation.getImageArrayClass(), Image.class};
}
/**
* {@inheritDoc}
*/
public String[] getPropertyTypeNames() {
return new String[] {"Boolean", "Image", "Image[]", "Image"};
}
/**
* {@inheritDoc}
*/
public Object getPropertyValue(String name) {
if(name.equals("eagerLock")) {
if(isEagerLock()) {
return Boolean.TRUE;
}
return Boolean.FALSE;
}
if(name.equals("image")) {
return getImage();
}
if(name.equals("imageList")) {
if(getImageList() == null) {
return null;
}
Image[] a = new Image[getImageList().getSize()];
int alen = a.length;
for(int iter = 0 ; iter < alen ; iter++) {
a[iter] = getImageList().getItemAt(iter);
}
return a;
}
if(name.equals("swipePlaceholder")) {
return getSwipePlaceholder();
}
return null;
}
/**
* {@inheritDoc}
*/
public String setPropertyValue(String name, Object value) {
if(name.equals("eagerLock")) {
setEagerLock(value != null && ((Boolean)value).booleanValue());
return null;
}
if(name.equals("image")) {
setImage((Image)value);
return null;
}
if(name.equals("imageList")) {
if(value == null) {
setImageList(null);
} else {
setImageList(new DefaultListModel<Image>((Image[])value));
}
return null;
}
if(name.equals("swipePlaceholder")) {
setSwipePlaceholder((Image)value);
return null;
}
return super.setPropertyValue(name, value);
}
/**
* {@inheritDoc}
*/
@Override
public void initComponent() {
super.initComponent();
if(image == null) {
// gui builder?
image = Image.createImage(50, 50, 0);
} else {
image.lock();
}
if(image.isAnimation()) {
getComponentForm().registerAnimated(this);
}
eagerLock();
}
private void eagerLock() {
if(eagerLock) {
if(swipeableImages != null && swipeableImages.getSize() > 1) {
Image left = getImageLeft();
if(swipePlaceholder != null) {
left.asyncLock(swipePlaceholder);
} else {
left.lock();
}
if(swipeableImages.getSize() > 2) {
Image right = getImageRight();
if(swipePlaceholder != null) {
right.asyncLock(swipePlaceholder);
} else {
right.lock();
}
}
}
}
}
private void eagerUnlock() {
if(eagerLock) {
if(swipeableImages != null && swipeableImages.getSize() > 1) {
getImageLeft().unlock();
getImageRight().unlock();
}
}
}
/**
* Returns the x position of the image viewport which can be useful when it is being panned by the user
* @return x position within the image for the top left corner
*/
public int getImageX() {
return imageX;
}
/**
* Returns the y position of the image viewport which can be useful when it is being panned by the user
* @return y position within the image for the top left corner
*/
public int getImageY() {
return imageY;
}
/**
* {@inheritDoc}
*/
@Override
public void deinitialize() {
super.deinitialize();
image.unlock();
eagerUnlock();
}
/**
* Initializes the component with an image
* @param i image to show
*/
public ImageViewer(Image i) {
this();
setImage(i);
}
/**
* {@inheritDoc}
*/
@Override
public void keyReleased(int key) {
if(swipeableImages != null) {
int gk = Display.getInstance().getGameAction(key);
if((gk == Display.GAME_LEFT || gk == Display.GAME_UP) && (cycleLeft || swipeableImages.getSelectedIndex() > getImageLeftPos())) {
new AnimatePanX(-1, getImageLeft(), getImageLeftPos());
return;
}
if(gk == Display.GAME_RIGHT || gk == Display.GAME_RIGHT && (cycleRight || swipeableImages.getSelectedIndex() < getImageRightPos())) {
new AnimatePanX(2, getImageRight(), getImageRightPos());
return;
}
}
}
/**
* {@inheritDoc}
*/
@Override
public void pointerPressed(int x, int y) {
pressX = x;
pressY = y;
currentZoom = zoom;
}
private Image getImageRight() {
return swipeableImages.getItemAt(getImageRightPos());
}
private int getImageRightPos() {
return (swipeableImages.getSelectedIndex() + 1) % swipeableImages.getSize();
}
private int getImageLeftPos() {
int pos = swipeableImages.getSelectedIndex() - 1;
if(pos < 0) {
return swipeableImages.getSize() - 1;
}
return pos;
}
private Image getImageLeft() {
return swipeableImages.getItemAt(getImageLeftPos());
}
/**
* {@inheritDoc}
*/
@Override
public void pointerReleased(int x, int y) {
super.pointerReleased(x, y);
if(panPositionX > 1) {
if(panPositionX >= 1 + swipeThreshold && (cycleRight || swipeableImages.getSelectedIndex() < getImageRightPos())) {
new AnimatePanX(2, getImageRight(), getImageRightPos());
} else {
// animate back
new AnimatePanX(1, null, 0);
}
return;
}
if(panPositionX < 0) {
if(panPositionX <= swipeThreshold * -1 && (cycleLeft || swipeableImages.getSelectedIndex() > getImageLeftPos())) {
new AnimatePanX(-1, getImageLeft(), getImageLeftPos());
} else {
// animate back
new AnimatePanX(0, null, 0);
}
return;
}
}
/**
* {@inheritDoc}
*/
@Override
public void pointerDragged(int x, int y) {
// could be a pan
float distanceX = ((float)pressX - x) / getZoom();
float distanceY = ((float)pressY - y) / getZoom();
// convert to a number between 0 - 1
distanceX /= ((float)getWidth());
distanceY /= ((float)getHeight());
// panning or swiping
if(getZoom() > 1) {
if(swipeableImages != null && swipeableImages.getSize() > 1) {
// this has the potential of being a pan operation...
if(panPositionX < 0 || panPositionX == 0 && distanceX < 0) {
panPositionX = ((float)pressX - x) / ((float)getWidth());
repaint();
return;
} else {
if(panPositionX > 1 || panPositionX == 1 && distanceX > 0) {
panPositionX = 1 + ((float)pressX - x) / ((float)getWidth());
repaint();
return;
}
}
}
pressX = x;
pressY = y;
panPositionX = panPositionX + distanceX * getZoom();
panPositionX = Math.min(1, Math.max(0, panPositionX));
panPositionY = Math.min(1, Math.max(0, panPositionY + distanceY * getZoom()));
updatePositions();
repaint();
} else {
if(swipeableImages != null && swipeableImages.getSize() > 1) {
panPositionX = distanceX;
// this has the potential of being a pan operation...
if(panPositionX < 0) {
repaint();
return;
} else {
if(panPositionX > 0) {
panPositionX += 1;
repaint();
return;
}
}
}
}
}
/**
* {@inheritDoc}
*/
@Override
protected void laidOut() {
super.laidOut();
updatePositions();
}
/**
* {@inheritDoc}
*/
@Override
protected boolean pinch(float scale) {
zoom = currentZoom * scale;
if(zoom < MIN_ZOOM) {
zoom = MIN_ZOOM;
} else {
if(zoom > MAX_ZOOM) {
zoom = MAX_ZOOM;
}
}
updatePositions();
repaint();
return true;
}
private void imageAspectCalc(Image img) {
if(img == null) {
return;
}
int iW = img.getWidth();
int iH = img.getHeight();
Style s = getStyle();
int width = getWidth() - s.getHorizontalPadding();
int height = getHeight() - s.getVerticalPadding();
float r2;
if(imageInitialPosition == IMAGE_FIT){
r2 = Math.min(((float)width) / ((float)iW), ((float)height) / ((float)iH));
}else{
r2 = Math.max(((float)width) / ((float)iW), ((float)height) / ((float)iH));
}
// calculate the image position to fit
prefW = (int)(((float)iW) * r2);
prefH = (int)(((float)iH) * r2);
prefX = s.getPaddingLeftNoRTL() + (width - prefW) / 2;
prefY = s.getPaddingTop() + (height - prefH) / 2;
}
private void updatePositions() {
if(zoom == 1) {
imageAspectCalc(image);
imageDrawWidth = prefW;
imageDrawHeight = prefH;
imageX = prefX;
imageY = prefY;
return;
}
int iW = image.getWidth();
int iH = image.getHeight();
Style s = getStyle();
int width = getWidth() - s.getHorizontalPadding();
int height = getHeight() - s.getVerticalPadding();
float r2;
if(imageInitialPosition == IMAGE_FIT){
r2 = Math.min(((float)width) / ((float)iW), ((float)height) / ((float)iH));
}else{
r2 = Math.max(((float)width) / ((float)iW), ((float)height) / ((float)iH));
}
imageDrawWidth = (int)(((float)iW) * r2 * zoom);
imageDrawHeight = (int)(((float)iH) * r2 * zoom);
imageX = (int)(s.getPaddingLeftNoRTL()+ (width - imageDrawWidth) * panPositionX);
imageY = (int)(s.getPaddingTop() + (height - imageDrawHeight) * panPositionY);
}
/**
* {@inheritDoc}
*/
@Override
protected Dimension calcPreferredSize() {
if(image != null) {
return new Dimension(image.getWidth(), image.getHeight());
}
return new Dimension(Display.getInstance().getDisplayWidth(), Display.getInstance().getDisplayHeight());
}
/**
* {@inheritDoc}
*/
@Override
public boolean animate() {
boolean result = false;
if(image != null && image.isAnimation()) {
result = image.animate();
if (result) {
updatePositions();
}
}
return super.animate() || result;
}
/**
* {@inheritDoc}
*/
@Override
public void paint(Graphics g) {
if(panPositionX < 0) {
Style s = getStyle();
int width = getWidth() - s.getHorizontalPadding();
float ratio = ((float)width) * (panPositionX * -1);
g.drawImage(image, ((int)ratio) + getX() + imageX, getY() + imageY, imageDrawWidth, imageDrawHeight);
if (cycleLeft || swipeableImages.getSelectedIndex() > getImageLeftPos()) {
Image left = getImageLeft();
if(swipePlaceholder != null) {
left.asyncLock(swipePlaceholder);
} else {
left.lock();
}
ratio = ratio - width;
imageAspectCalc(left);
g.drawImage(left, ((int)ratio) + getX() + prefX, getY() + prefY, prefW, prefH);
}
return;
}
if(panPositionX > 1) {
Style s = getStyle();
int width = getWidth() - s.getHorizontalPadding();
float ratio = ((float)width) * (1 - panPositionX);
g.drawImage(image, ((int)ratio) + getX() + imageX, getY() + imageY, imageDrawWidth, imageDrawHeight);
if (cycleRight || swipeableImages.getSelectedIndex() < getImageRightPos()) {
Image right = getImageRight();
if(swipePlaceholder != null) {
right.asyncLock(swipePlaceholder);
} else {
right.lock();
}
ratio = ratio + width;
imageAspectCalc(right);
g.drawImage(right, ((int)ratio) + getX() + prefX, getY() + prefY, prefW, prefH);
}
return;
}
// can happen in the GUI builder
if(image != null) {
g.drawImage(image, getX() + imageX, getY() + imageY, imageDrawWidth, imageDrawHeight);
}
}
/**
* {@inheritDoc}
*/
@Override
protected void paintBackground(Graphics g) {
// disable background painting for performance when zooming
if(imageDrawWidth < getWidth() || imageDrawHeight < getHeight()) {
super.paintBackground(g);
}
}
/**
* Returns the currently showing image
* @return the image
*/
public Image getImage() {
return image;
}
/**
* Sets the currently showing image
* @param image the image to set
*/
public void setImage(Image image) {
if(this.image != image) {
panPositionX = 0.5f;
panPositionY = 0.5f;
zoom = MIN_ZOOM;
this.image = image;
updatePositions();
repaint();
if(image.isAnimation()) {
Form f = getComponentForm();
if(f != null) {
f.registerAnimated(this);
}
}
}
}
/**
* Sets the current image without any changes to the panning/scaling
* @param image new image instance
*/
public void setImageNoReposition(Image image) {
this.image = image;
repaint();
}
/**
* By providing this optional list of images you can allows swiping between multiple images
*
* @param model a list of images
*/
public void setImageList(ListModel<Image> model) {
if(model == null || model.getSize() == 0) {
return;
}
if(image == null) {
image = model.getItemAt(0);
}
if(swipeableImages != null) {
swipeableImages.removeDataChangedListener(listListener);
swipeableImages.removeSelectionListener((SelectionListener)listListener);
model.addDataChangedListener(listListener);
model.addSelectionListener((SelectionListener)listListener);
} else {
class Listener implements SelectionListener, DataChangedListener {
public void selectionChanged(int oldSelected, int newSelected) {
if(selectLock) {
return;
}
if(swipeableImages.getSize() > 0 && newSelected > -1 && newSelected < swipeableImages.getSize()) {
setImage(swipeableImages.getItemAt(newSelected));
}
}
public void dataChanged(int type, int index) {
if(swipeableImages.getSize() > 0 && swipeableImages.getSelectedIndex() > -1 && swipeableImages.getSelectedIndex() < swipeableImages.getSize()) {
setImage(swipeableImages.getItemAt(swipeableImages.getSelectedIndex()));
}
}
}
listListener = new Listener();
model.addDataChangedListener(listListener);
model.addSelectionListener((SelectionListener)listListener);
}
this.swipeableImages = model;
}
/**
* Returns the list model containing the images in the we can swipe through
* @return the list model
*/
public ListModel<Image> getImageList() {
return swipeableImages;
}
/**
* Manipulate the zoom level of the application
* @return the zoom
*/
public float getZoom() {
return zoom;
}
/**
* Manipulate the zoom level of the application
* @param zoom the zoom to set
*/
public void setZoom(float zoom) {
this.zoom = zoom;
updatePositions();
repaint();
}
/**
* This image is shown briefly during swiping while the full size image is loaded
* @return the swipePlaceholder
*/
public Image getSwipePlaceholder() {
return swipePlaceholder;
}
/**
* This image is shown briefly during swiping while the full size image is loaded
* @param swipePlaceholder the swipePlaceholder to set
*/
public void setSwipePlaceholder(Image swipePlaceholder) {
this.swipePlaceholder = swipePlaceholder;
}
/**
* Eager locking effectively locks the right/left images as well as the main image, as a result
* more heap is taken
* @return the eagerLock
*/
public boolean isEagerLock() {
return eagerLock;
}
/**
* Eager locking effectively locks the right/left images as well as the main image, as a result
* more heap is taken
* @param eagerLock the eagerLock to set
*/
public void setEagerLock(boolean eagerLock) {
this.eagerLock = eagerLock;
}
/**
* By default the ImageViewer cycles from the beginning to the end of the list
* when going to the left, setting this to false prevents this behaviour
* @return true if it should cycle left from beginning
*/
public boolean isCycleLeft() {
return cycleLeft;
}
/**
* By default the ImageViewer cycles from the beginning to the end of the list
* when going to the left, setting this to false prevents this behaviour
* @param cycleLeft the cycle left to set
*/
public void setCycleLeft(boolean cycleLeft) {
this.cycleLeft = cycleLeft;
}
/**
* By default the ImageViewer cycles from the end to the beginning of the list
* when going to the right, setting this to false prevents this behaviour
* @return true if it should cycle right from the end
*/
public boolean isCycleRight() {
return cycleRight;
}
/**
* By default the ImageViewer cycles from the end to the beginning of the list
* when going to the right, setting this to false prevents this behaviour
* @param cycleRight the cycle right to set
*/
public void setCycleRight(boolean cycleRight) {
this.cycleRight = cycleRight;
}
/**
* The swipe threshold is a number between 0 and 1 that indicates the threshold
* after which the swiped image moves to the next image. Below that number the image
* will bounce back
* @return the threshold
*/
public float getSwipeThreshold() {
return swipeThreshold;
}
/**
* The swipe threshold is a number between 0 and 1 that indicates the threshold
* after which the swiped image moves to the next image. Below that number the image
* will bounce back
* @param swipeThreshold the swipeThreshold to set
*/
public void setSwipeThreshold(float swipeThreshold) {
this.swipeThreshold = swipeThreshold;
}
class AnimatePanX implements Animation {
private Motion motion;
private Image replaceImage;
private int updatePos;
public AnimatePanX(float destPan, Image replaceImage, int updatePos) {
motion = Motion.createEaseInOutMotion((int)(panPositionX * 10000), (int)(destPan * 10000), 200);
motion.start();
this.replaceImage = replaceImage;
this.updatePos = updatePos;
Display.getInstance().getCurrent().registerAnimated(this);
}
public boolean animate() {
float v = motion.getValue();
v /= 10000.0f;
panPositionX = v;
if(motion.isFinished()) {
if(replaceImage != null) {
if(!eagerLock) {
getImage().unlock();
setImage(replaceImage);
} else {
setImage(replaceImage);
Image left = getImageLeft();
Image right = getImageRight();
if(left != replaceImage) {
left.unlock();
}
if(right != replaceImage) {
right.unlock();
}
selectLock = true;
swipeableImages.setSelectedIndex(updatePos);
selectLock = false;
replaceImage.lock();
eagerLock();
}
selectLock = true;
swipeableImages.setSelectedIndex(updatePos);
selectLock = false;
panPositionX = 0.5f;
panPositionY = 0.5f;
zoom = MIN_ZOOM;
} else {
// free cached memory
if(swipeableImages != null && swipeableImages.getSize() > 1) {
getImageLeft().unlock();
getImageRight().unlock();
}
}
Display.getInstance().getCurrent().deregisterAnimated(this);
}
repaint();
return false;
}
public void paint(Graphics g) {
}
}
/**
* Sets the viewer initial image position to fill or to fit.
* @param imageInitialPosition values can be IMAGE_FILL or IMAGE_FIT
*/
public void setImageInitialPosition(int imageInitialPosition) {
this.imageInitialPosition = imageInitialPosition;
}
}