Skip to content

Instantly share code, notes, and snippets.

@james-d
Last active July 16, 2018 23:36
Show Gist options
  • Save james-d/a249470377fb3c58784a9349a22641c4 to your computer and use it in GitHub Desktop.
Save james-d/a249470377fb3c58784a9349a22641c4 to your computer and use it in GitHub Desktop.
Example of (essentially) infinite panning in JavaFX using a fixed set of ImageViews and updating their images on panning. Prototype for Google Maps type of interface.
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.DoublePropertyBase;
import javafx.beans.property.ReadOnlyIntegerWrapper;
import javafx.geometry.Pos;
import javafx.scene.image.Image;
import javafx.scene.layout.Pane;
/**
*
* Pane that holds a two-dimensional array of tiles, which
* are wrappers for ImageViews. The pane can be panned by
* dragging the mouse, and the tiles will be reused to display
* new images.
*
* The PanningTiledPane requires a factory for creating the images
* to be displayed in the tiles, which is represented by an ImageSupplier.
* ImageSupplier is a FunctionalInterface defining a method taking the
* location in the array of the tile (as two ints) and returning the
* Image. This method will be invoked on a background thread when a new
* tile is required, and the tile will be updated with the resulting
* image when it completes.
*
*/
public class PanningTiledPane extends Pane {
private final int tileWidth ;
private final int tileHeight ;
// amount scrolled left, in pixels:
private final DoubleProperty xOffset = new DoublePropertyBase() {
@Override
public Object getBean() {
return this;
}
@Override
public String getName() {
return "xOffset";
}
@Override
protected void invalidated() {
requestLayout();
}
};
// amount scrolled up, in pixels:
private final DoubleProperty yOffset = new DoublePropertyBase() {
@Override
public Object getBean() {
return this;
}
@Override
public String getName() {
return "yOffset";
}
@Override
protected void invalidated() {
requestLayout();
}
};
// number of whole tiles shifted to left:
private final ReadOnlyIntegerWrapper tileXOffset = new ReadOnlyIntegerWrapper();
// number of whole tiles shifted up:
private final ReadOnlyIntegerWrapper tileYOffset = new ReadOnlyIntegerWrapper();
// for enabling dragging:
private double mouseAnchorX;
private double mouseAnchorY;
private final ImageSupplier imageSupplier ;
private final Map<GridLocation, Tile> tileMap = new HashMap<>();
private final Set<Tile> availableTiles = new HashSet<>();
/**
*
* @param tileWidth The (fixed) width of each tile.
* @param tileHeight The (fixed) height of each tile.
* @param imageSupplier A factory for creating images for a given tile, which will
* be invoked when needed on a background thread - consequently, this function should
* not directly access nodes that are part of a scene graph that is displayed.
*/
public PanningTiledPane(int tileWidth, int tileHeight, ImageSupplier imageSupplier) {
super();
this.tileWidth = tileWidth;
this.tileHeight = tileHeight;
this.imageSupplier = imageSupplier ;
// update number of tiles offset when number of pixels offset changes:
tileXOffset.bind(xOffset.divide(tileWidth));
tileYOffset.bind(yOffset.divide(tileHeight));
// enable panning on pane (just update offsets when dragging):
setOnMousePressed(e -> {
mouseAnchorX = e.getSceneX();
mouseAnchorY = e.getSceneY();
});
setOnMouseDragged(e -> {
double deltaX = e.getSceneX() - mouseAnchorX;
double deltaY = e.getSceneY() - mouseAnchorY;
xOffset.set(xOffset.get() + deltaX);
yOffset.set(yOffset.get() + deltaY);
mouseAnchorX = e.getSceneX();
mouseAnchorY = e.getSceneY();
});
}
@Override
protected void layoutChildren() {
int minColIndex = (int) Math.floor(-xOffset.get() / tileWidth);
int maxColIndex = (int) Math.ceil((-xOffset.get() + getWidth()) / tileWidth);
int minRowIndex = (int) Math.floor(-yOffset.get() / tileHeight);
int maxRowIndex = (int) Math.ceil((-yOffset.get() + getHeight()) / tileHeight);
for (Iterator<Map.Entry<GridLocation, Tile>> it = tileMap.entrySet().iterator(); it.hasNext();) {
Map.Entry<GridLocation, Tile> entry = it.next();
if (! entry.getKey().inRangeInclusive(minColIndex, maxColIndex, minRowIndex, maxRowIndex)) {
it.remove();
removeTile(entry.getValue());
availableTiles.add(entry.getValue());
}
}
for (int col = minColIndex ; col <= maxColIndex ; col++) {
double layoutX = xOffset.get() % tileWidth + (col+tileXOffset.get()) * tileWidth ;
for (int row = minRowIndex ; row <= maxRowIndex ; row++) {
double layoutY = yOffset.get() % tileHeight + (row+tileYOffset.get()) * tileHeight ;
GridLocation loc = new GridLocation(col, row);
Tile tile = tileMap.computeIfAbsent(loc, location -> {
Tile t ;
if (availableTiles.isEmpty()) {
t = createTile(loc.getColumn(), loc.getRow());
} else {
t = availableTiles.iterator().next();
availableTiles.remove(t);
t.setLocation(loc.getColumn(), loc.getRow());
}
getChildren().add(t.getView());
return t ;
});
tile.getView().setLayoutX(layoutX);
tile.getView().setLayoutY(layoutY);
}
}
// prune available tiles:
int maxAvailableToKeep = maxColIndex - minColIndex + maxRowIndex - minRowIndex + 3 ;
int toRemove = availableTiles.size() - maxAvailableToKeep ;
int removed = 0 ;
for (Iterator<Tile> it = availableTiles.iterator() ; it.hasNext() && removed < toRemove ; removed++) {
it.next(); it.remove();
}
}
private Tile createTile(int col, int row) {
return new Tile(col - tileXOffset.get(), row - tileYOffset.get(),
this.tileWidth, this.tileHeight, imageSupplier);
}
private void removeTile(Tile tile) {
getChildren().remove(tile.getView());
}
/**
* Move the tile is specified by the given tile coordinates
* to the specified position in the pane.
* @param tileX
* @param tileY
*/
public void moveTo(int tileX, int tileY, Pos pos) {
int xPos ;
switch (pos.getHpos()) {
case LEFT: xPos = 0 ; break;
case CENTER: xPos = 1 ; break ;
case RIGHT: xPos = 2 ; break ;
default: xPos = 0 ;
}
int yPos ;
switch (pos.getVpos()) {
case TOP: yPos = 0 ; break ;
case CENTER: yPos = 1 ; break ;
case BASELINE:
case BOTTOM: yPos = 2 ; break ;
default: yPos = 0 ;
}
xOffset.set(tileX * tileWidth + (getWidth() - tileWidth) * xPos / 2);
yOffset.set(tileY * tileHeight + (getHeight() - tileHeight) * yPos / 2);
}
private static class GridLocation {
private final int column ;
private final int row ;
// cache hashcode as we know we are using these in hashmaps
private final int hash ;
public GridLocation(int column, int row) {
super();
this.column = column;
this.row = row;
this.hash = 31 * (column + 31 * row);
}
public int getColumn() {
return column;
}
public int getRow() {
return row;
}
@Override
public boolean equals(Object obj) {
if (! (obj instanceof GridLocation)) {
return false ;
}
GridLocation other = (GridLocation) obj ;
return other.column == column && other.row == row ;
}
@Override
public int hashCode() {
return hash ;
}
public boolean inRangeInclusive(int minCol, int maxCol, int minRow, int maxRow) {
return column >= minCol && column <= maxCol
&& row >= minRow && row <= maxRow ;
}
}
/**
*
* A function that maps the location to an image.
* The Tile class will call this function in a background thread,
* throwing InterruptedExceptions will allow the Tile class
* to cancel invocations.
*
*/
@FunctionalInterface
public static interface ImageSupplier {
public Image getImage(int x, int y) throws InterruptedException ;
}
}
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.logging.Level;
import java.util.logging.Logger;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class PanningTilesExample extends Application {
private static final int TILE_WIDTH = 100;
private static final int TILE_HEIGHT = 100;
private final Random rng = new Random();
@Override
public void start(Stage primaryStage) {
PanningTiledPane tiledPane = new PanningTiledPane(TILE_WIDTH, TILE_HEIGHT, this::getImage);
tiledPane.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
tiledPane.moveTo(0, 0);
}
});
Scene scene = new Scene(new StackPane(tiledPane), 800, 800);
primaryStage.setScene(scene);
primaryStage.show();
}
// get a new image for tile represented by column, row
// this implementation just snapshots a label, but this could be
// retrieved from a file, server, or database, etc
private Image getImage(int column, int row) throws InterruptedException {
// simulate slow loading from database, etc:
Random rng = new Random();
Thread.sleep(rng.nextInt(1000));
// little hack to create image from snapshot and return it when we're running
// on background thread (snapshot must be called from FX Application Thread):
FutureTask<Image> runOnFXThread = new FutureTask<>(() -> {
Label label = new Label(String.format("Tile [%d,%d]", column, row));
label.setPrefSize(TILE_WIDTH, TILE_HEIGHT);
label.setMaxSize(TILE_WIDTH, TILE_HEIGHT);
label.setAlignment(Pos.CENTER);
label.setStyle(String.format("-fx-background: %s; "
+ "-fx-background-color: -fx-background;",
randomColorString()));
// must add label to a scene for background to work:
new Scene(label);
return label.snapshot(null, null);
});
Platform.runLater(runOnFXThread);
try {
return runOnFXThread.get() ;
} catch (ExecutionException e) {
Logger.getLogger(getClass().getName()).log(Level.SEVERE, "Error creating image", e);
return null ;
}
}
private String randomColorString() {
return String.format("#%02x%02x%02x", rng.nextInt(256), rng.nextInt(256), rng.nextInt(256));
}
public static void main(String[] args) {
launch(args);
}
}
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.scene.Node;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
/**
* Represents an image "Tile" in a two-dimensional array, with
* support for background loading of the images. Images are created
* via a user-supplied function, which is executed in a background thread.
*
*/
class Tile {
private int x ;
private int y ;
private final ImageView view ;
private final Service<Image> imageService ;
/**
*
* Creates a new tile, with the given location and (fixed) dimensions. The
* imageSupplier will be invoked in a background thread and the resulting image
* displayed in the view on successful completion.
*
* @param x Initial x-coordinate of tile.
* @param y Initial y-coordinate of tile.
* @param width Width of tile in pixels (fixed).
* @param height Height of tile in pixels (fixed).
* @param imageSupplier Function for creating images, which will be invoked on a background thread.
*
*/
public Tile(int x, int y, double width, double height, PanningTiledPane.ImageSupplier imageSupplier) {
this.x = x ;
this.y = y ;
this.view = new ImageView();
view.setFitWidth(width);
view.setFitHeight(height);
this.imageService = new Service<Image>() {
@Override
protected Task<Image> createTask() {
final int tileX = getX() ;
final int tileY = getY() ;
return new Task<Image>() {
@Override
protected Image call() throws Exception {
return imageSupplier.getImage(tileX, tileY);
}
};
}
};
this.imageService.stateProperty().addListener((obs, oldState, newState) -> {
if (newState == Worker.State.SUCCEEDED) {
view.setImage(imageService.getValue());
} else {
view.setImage(null);
}
});
imageService.start();
}
/**
* Sets the location and updates the image with an existing image.
* @param x The new x-coordinate in the grid.
* @param y The new y-coordinate in the grid.
* @param image The new image. If this is null, a new image will be loaded in the background.
*/
public void setLocationAndImage(int x, int y, Image image) {
this.x = x ;
this.y = y ;
if (image == null) {
imageService.restart();
} else {
imageService.cancel();
view.setImage(image);
}
}
/**
* Sets the location and loads a new image in the background.
* @param x
* @param y
*/
public void setLocation(int x, int y) {
setLocationAndImage(x, y, null);
}
/**
*
* @return The current image.
*/
public Image getImage() {
return view.getImage();
}
/**
*
* @return The view for this tile.
*/
public Node getView() {
return view ;
}
/**
*
* @return The current x-coordinate.
*/
public int getX() {
return x ;
}
/**
*
* @return The current y-coordinate.
*/
public int getY() {
return y ;
}
}
@igoriuz
Copy link

igoriuz commented Jul 16, 2018

Thank you very much for sharing this. Unfortunately i have some problems with this implementation:

  • If you resize the window a few times, some tiles are misplaced (in regards to the coordinates) or even exist twice at wrong positions. Do you have any idea how it is possible to fix it? It has to do something with the behavior during resizing because if you move in the pane back and forth to refresh the broken tiles then everything is at its correct position.
  • In this example you forgot to add a Pos enum to the moveTo method.
  • The moveTo method does not calculate the correct position because both tileX and tileY should be multiplied with negative tileWidth and tileHeight

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment