Module 5 Task 2: Adding Rotate, Zoom, Pan, and Swipe Features
- Highlights
-
- Implement the feature to rotate, zoom, pan, and swipe images on a mobile device screen
Introduction
Module 5 Task 1 of the Media Browser tutorial adapted the Media Browser application to run on a mobile device. Module 5 Task 2 section adds the capability of rotating the content displayed on the mobile device screen from a portrait (vertical) view to a landscape (horizontal) view. This task also adds the capability to swipe - scroll one page of thumbnails at a time, zoom - further enlarge the selected image, and pan - move the zoomed image horizontally and vertically within the viewport. This task builds on the foundation laid in Module 5 Task 1, so a review of that tutorial is recommended.
Running the Project
In this task, only the module05-task02-mobile project is needed
because the features are available for mobile devices only. Note that
the module05-task02-mobile project can be built and run
only on Windows, as the JavaFX mobile emulator that is used in this
task is currently available for the Windows platform only.
- Download and unzip the compressed file of Module 5 Task 2 NetBeans projects.
Themodule05-task02_nb.zipfile contains thetutorialsfolder and its subfolders that include the three NetBeans projects that were described in Module 5 Task 1. In this task, however, the project names are nowmodule05-task02-common,module05-task02-desktop, andmodule05-task02-mobile. - Start the NetBeans IDE and open the
module05-task02-mobileproject in the IDE to run the mobile version of the application, as shown in the mobile emulator in Figure 1.
Figure 1 - For instructions on searching for new images, see the section on Running the Project in Module 5 Task 1.
- To rotate the mobile emulator display from portrait to landscape,
choose View > Orientation > 90° to rotate , or to rotate from
landscape to portrait, choose View > Orientation > 0° from the
main menu of the mobile emulator as shown in Figure 2.
Note: Only the options to rotate 0° or 90° have been implemented in this release.
Figure 2 - The mobile emulator rotates 90°, as shown in Figure 3.
Figure 3 - To use the swipe feature, place the cursor on the screen, then
click and hold as you move the cursor across the screen. This action
causes the wall of thumbnails to scroll one page at a time.
Note: When you use swipe in the mobile emulator, the cursor must remain within the boundary of the mobile device screen. If the cursor is moved beyond the edges of the screen, the application might not recognize the release of the mouse button and the thumbnail images will not reload until you click the mobile device screen. - To use the zoom feature, select the image you want to enlarge.
Click the right soft key under the magnifying glass icon to zoom.Tap or
click the image again to restore it to thumbnail size.
- To use the pan feature, first follow the steps to zoom the image,
then use the arrow keys either on the keyboard or the mobile emulator
to pan the image.
Note: To use features such as swipe and zoom with a finger drag or tap, the mobile device must have touch-screen capability.
Architecture
This task adds the Globals.fx class, which contains variables that are used to set the application's view orientation. It also updates the MobileWall.fx class, as shown in light blue text in Figure 4.
Figure 4
Rotating the View on a Mobile Device
When you choose View > Orientation > 90° from the main menu of the JavaFX mobile emulator, it is as if you have physically turned the phone on its side. The view of the Media Browser application rotates, as shown in Figure 3. The number of thumbnails changes from three rows to two so that the application better fits the emulator's display screen. Also, notice on the display that the icons for the rotate and search buttons are in the correct places and they still provide the correct affordances with the left and right soft keys. Just as expected, the scrollbar works and clicking the image expands the image. When you return to 0° orientation using View > Orientation > 0°, the display reverts to the way it was before.
The rotation of the application's view emulates a hardware-driven change to the Stage's height and width. The positions and sizes of the title bar and scroll bar are defined in Wall.fx based on the values of Constants.STAGE_HEIGHT and Constants.STAGE_WIDTH. If Constants.STAGE_HEIGHT and Constants.STAGE_WIDTH were bound to the Stage's
height and width, the view of the application should also resize and
reposition itself correctly when the view orientation of the mobile
emulator changes. However, the STAGE_HEIGHT and STAGE_WIDTH values have been constants since they were introduced in Module 1 Task 1.
To accommodate the changes in the stage's height and width, the STAGE_HEIGHT and STAGE_WIDTH need to be treated as variables instead of constants and they need to be accessible globally. Hence, a new Globals.fx
source file is introduced as a place to hold these and other variables
used to determine how the thumbnails should be displayed when the
application's view is rotated. Constants.STAGE_HEIGHT and Constants.STAGE_WIDTH are now Globals.STAGE_HEIGHT and Globals.STAGE_WIDTH variables. The values of the variables that are defined in Globals.fx are initialized in Main.fx, as shown next.
Globals.THUMB_HEIGHT = 69; Globals.THUMB_WIDTH = 92; Globals.THUMB_VERTICAL_SPACING = 12; Globals.THUMB_HORIZONTAL_SPACING = 16;
Detecting the hardware-driven change in the view orientation occurs in Main.fx, where the local variables mobile_stage_height and mobile_stage_width are bound to the variables height and width of stage. Notice these variables' replace triggers, which are blocks of code that set the values of Globals.STAGE_HEIGHT and Globals.STAGE_WIDTH when the stage's height and width change. The triggers also reset Globals.ROTATE_MODE, which is explained later in this section.
The following code snippet from Main.fx shows the use of the keyword bind, which creates an immediate and direct relationship between two variables: mobile_stage_height with stage.height and mobile_stage_width with stage.width. To determine whether the application is in portrait or landscape mode, both the width and the height need to be determined. The call to isInitialized ensures that both the width and height are initialized.
var mobile_stage_height: Number = bind stage.height on replace {
Globals.STAGE_HEIGHT = mobile_stage_height;
if (isInitialized(Globals.STAGE_WIDTH)) {
Globals.ROTATE_MODE = false;
if (Globals.STAGE_WIDTH > Globals.STAGE_HEIGHT ) {
// landscape
Globals.PORTRAIT_ORIENTATION = false;
}
else {
// portrait
Globals.PORTRAIT_ORIENTATION = true;
}
}
};
var mobile_stage_width: Number = bind stage.width on replace {
Globals.STAGE_WIDTH = mobile_stage_width;
if (isInitialized(Globals.STAGE_HEIGHT)) {
Globals.ROTATE_MODE = false;
if (Globals.STAGE_WIDTH > Globals.STAGE_HEIGHT ) {
// landscape
Globals.PORTRAIT_ORIENTATION = false;
}
else {
// portrait
Globals.PORTRAIT_ORIENTATION = true;
}
}
};
In the context of this tutorial, rotate is a software-driven change to the height and width of the application. When the left soft key is pressed or the rotate icon
is clicked on the emulator, the stage's height and width do not change. In other words, the orientation of the stage's x and y coordinates does not change. Rather, the values for Globals.STAGE_HEIGHT and Globals.STAGE_WIDTH
are swapped. Another way to look at it is that the emulator is in the
portrait view, but the application is in landscape view. In the source
code, the following two variables are used to track the stage's
orientation and application's rotation values.
Globals.PORTRAIT_ORIENTATIONis set totrueif theGlobals.STAGE_HEIGHTis greater thanGlobals.STAGE_WIDTH.Globals.ROTATE_MODEis set totruewhen the user presses the left soft key or clicks the rotate button in the emulator. The code that handles this action can be found inMobileWall.fx.
Because the orientation of the stage is not changed by the rotate action, the x and y
coordinate system is out of phase with the application's notion of
height and width. This discrepancy can be confusing because, when the
application is rotated, the coordinates from the scene graph's
perspective are unchanged in the code. This means that the code must
itself translate x-coordinates into y-coordinates and vice-versa. Fortunately, this becomes an issue only in a few places in the code where Globals.ROTATE_MODE is used. Some of these are further explained next.
In MobileWall.fx, the icons for the rotate button
and search button
are positioned so that they are always adjacent to their corresponding
soft key. In the normal (unrotated and 0° orientation of the emulator),
the rotate button icon is positioned so that it is three pixels from
the right and three pixels from the bottom. The calculation for translateY is as follows:
Globals.STAGE_HEIGHT - Constants.ROTATE_BUTTON_HEIGHT - 3
When the application is rotated, the icon should be in the same place, but now the application's notion of width is along the y-axis. What was width is now height and reserve. So, the calculation for translateY when ROTATE_MODE is true is as follows:
Globals.STAGE_WIDTH - Constants.ROTATE_BUTTON_HEIGHT - 3
As far as the scene graph is concerned, these are the same translations. The same logic applies to the search button.
In Wall.fx, the code that handles keyboard navigation
must also compensate with the rotation. Before rotating the
application, when the right navigation key is pressed, the current
selection moved to the next icon in the same row. Pressing the up
navigation key would move the current selection to the previous icon in
the same column. When the application is rotated, the rows are
positioned along the y-axis and the columns are placed along the x-axis.
The right navigation key now becomes the up navigation key and the up
navigation key now becomes the right navigation key. Notice this change
applies only to ROTATE_MODE.
if ( not Globals.ROTATE_MODE ) {
if (evt.code == KeyCode.VK_LEFT) {
newThumbIndex -= Globals.THUMB_ROWS;
} else if (evt.code == KeyCode.VK_RIGHT) {
newThumbIndex += Globals.THUMB_ROWS;
} else if (evt.code == KeyCode.VK_UP) {
newThumbIndex -= 1;
} else if (evt.code == KeyCode.VK_DOWN) {
newThumbIndex += 1;
} else {
return;
}
} else {
if (evt.code == KeyCode.VK_DOWN) {
newThumbIndex -= Globals.THUMB_ROWS;
} else if (evt.code == KeyCode.VK_UP) {
newThumbIndex += Globals.THUMB_ROWS;
} else if (evt.code == KeyCode.VK_LEFT) {
newThumbIndex -= 1;
} else if (evt.code == KeyCode.VK_RIGHT) {
newThumbIndex += 1;
} else {
return;
}
}
Perhaps the most complicated piece of code comes just after the
handling of the keyboard navigation where the code ensures that the
newly selected thumbnail is visible. The code originally used the minX and maxX of the thumbnail's getBoundsInScene()
function to determine whether or not the thumbnail was fully visible.
When the application is rotated, though, the thumbnail's width is along
the y-axis. So the maxY value needs to be used in place of minX and the minY value in place of maxX, as shown in the following:
var newMinPos = if ( not Globals.ROTATE_MODE )
newThumbWithFocus.getBoundsInScene().minX
else
newThumbWithFocus.getBoundsInScene().maxY;
var newMaxPos = if ( not Globals.ROTATE_MODE )
newThumbWithFocus.getBoundsInScene().maxX
else
newThumbWithFocus.getBoundsInScene().minY;
var oldMinPos = if ( not Globals.ROTATE_MODE )
oldThumbWithFocus.getBoundsInScene().minX
else
oldThumbWithFocus.getBoundsInScene().maxY;
The direction of scrolling is reversed when the application is in ROTATE_MODE. You can see this change in ScrollControl.fx where the direction of the drag event is reversed:
var dragDistance = if ( not Globals.ROTATE_MODE ) evt.dragX else -evt.dragY;
Similar to the rotate and search icons, the Cancel and Done buttons
on the search dialog box require special treatment as the buttons
should align with the left and right soft keys, respectively. Unlike
the rotate and search icons, the Cancel and Done buttons must be
rotated when the emulator is in a landscape orientation. Note
especially the transformation sequence for a Node. Transformations are
applied in this order: effect, opacity, clip, transforms, scaleX and scaleY, rotate, translateX, and translateY. In the following MobileSearchTextBox.fx code, the variable for the left soft key, lsk, is first rotated 90° about its origin, then it is moved into place by application of the translateX and translateY transformations. The right soft key affordance, rsk, is handled the same way.
def lsk: Group = Group {
translateX: bind
if (Globals.PORTRAIT_ORIENTATION or Globals.ROTATE_MODE ) 1
else lskButton.height + 1
translateY: bind
if (Globals.PORTRAIT_ORIENTATION) Globals.STAGE_HEIGHT - lskButton.height - 1
else if (Globals.ROTATE_MODE) Globals.STAGE_WIDTH - lskButton.height - 1
else 1
transforms: bind
if (Globals.ROTATE_MODE or Globals.PORTRAIT_ORIENTATION) null
else Transform.rotate(90, 0, 0)
content: [ lskButton lskLabel ]
onMousePressed: function(me: MouseEvent) : Void {
if ( onClose != null ) {
onClose();
}
}
}
Zoom and Pan Images
An image enlarges when you press the Select key or click its
thumbnail. After it is enlarged, you can zoom into the image and use
the navigation keys to pan the image or move the image horizontally and
vertically. The code to support this functionality is found in Photo.fx.
Handling the key presses is at the center of the zoom and pan functionalities. When an image is shown on a mobile device, the Node that contains the ImageView class and has keyboard focus is mediabrowser.Photo.
Clicking the right soft key on the mobile emulator or pressing the F2
key on the computer keyboard enters or exits the zoom mode. After the
image is zoomed, the navigation keys move or pan the image up, down,
left, or right. These actions are all handled in the function handleKeyPressed, which overrides the handleKeyPressed function in mediabrowser.Media class file. This function was added to the Media class as a proxy to the onKeyPressed function.
override protected function handleKeyPressed(ke: KeyEvent): Void {
if (ke.code == KeyCode.VK_SOFTKEY_1) {
toggleZoom();
} else if (ke.code == KeyCode.VK_UP) {
pan(0, -DY);
} else if (ke.code == KeyCode.VK_DOWN) {
pan(0, DY);
} else if (ke.code == KeyCode.VK_LEFT) {
pan(-DX, 0);
} else if (ke.code == KeyCode.VK_RIGHT) {
pan(DX, 0);
} else {
super.handleKeyPressed(ke);
}
}
The toggleZoom function initializes the variables panX and panY. These variables track the pan offset on the x-axis and y-axis
from the upper left corner of the image and are initially set so that
the image is centered. Zooming is accomplished by adding a viewport to the ImageView. Only the portion of the image that falls within the viewport is displayed.
override var toggleZoom = function() {
if (zoomFactor == 1) {
zoomFactor = 2;
def bounds = imageView.getBoundsInScene();
panX = bounds.width / zoomFactor / 2;
panY = bounds.height / zoomFactor / 2;
var rect = Rectangle2D {
minX: panX;
minY: panY;
width: bounds.width / zoomFactor;
height: bounds.height / zoomFactor;
}
imageView.scaleX = zoomFactor;
imageView.scaleY = zoomFactor;
imageView.x = rect.minX;
imageView.y = rect.minY;
imageView.viewport = rect;
} else {
zoomFactor = 1;
imageView.scaleX = 1;
imageView.scaleY = 1;
imageView.x = 0;
imageView.y = 0;
imageView.viewport = null;
}
}
When the user presses a navigation key, the pan function is called and it recalculates the value of the variables panX and panY to create a new viewport for the ImageView. The arguments to the function (dx, dy) are expressed as a delta from the current pan location. Panning is constrained so that the image viewport never extends outside of the actual image. Note that Rectangle2D objects are immutable, so the viewport is replaced with a newly constructed Rectangle2D object.
function pan(dx: Number, dy: Number): Void {
if (zoomFactor == 1) {
return;
}
def viewW = imageView.localToScene(imageView.boundsInLocal).width;
def viewH = imageView.localToScene(imageView.boundsInLocal).height;
def zoomW = viewW / zoomFactor;
def zoomH = viewH / zoomFactor;
panX += dx;
panY += dy;
if (panX < 0) {
panX = 0;
}
if (panY < 0) {
panY = 0;
}
if (panX + zoomW > viewW) {
panX = viewW - zoomW;
}
if (panY + zoomH > viewH) {
panY = viewH - zoomH;
}
imageView.viewport = Rectangle2D {
minX: panX
minY: panY
width: zoomW;
height: zoomH;
};
}
When viewing the thumbnails, pressing the right soft key displays
the search dialog box. The affordance for the right soft key is the
icon, which also acts as a button. When the selected image is enlarged,
however, the meaning of the the right soft key changes from "search" to
"zoom." Pressing the right soft key enters zoom mode. Because the
meaning of the key changes, the affordance is changed to the zoom icon
.
MobileWall.fx already contains the code and logic to
handle the search icon and only a few changes are needed to make this
code display the zoom icon. Wall.fx has a variable called isMediaShowing that is set to true
if the enlarged image is being shown. This variable can be used to
determine whether to show the search icon or the zoom icon by using it
in a bind code block in the ImageView of the searchButton variable, as shown next.
content: [
ImageView {
image: bind
if ( not isMediaShowing ) then
Constants.SEARCH_ICON
else
Constants.ZOOM_ICON
fitWidth: Constants.SEARCH_BUTTON_WIDTH
fitHeight: Constants.SEARCH_BUTTON_HEIGHT
}
]
The previous code is enough to change the search icon to the zoom
icon, but the search icon also acts as a button. When the search icon
is pressed, the showDialog variable is set to true, causing the search dialog box to be shown. When the isMediaShowing variable is set to true, however, the icon will be the zoom icon and you want to invoke the toggleZoom function in Photo.fx.
onMouseClicked: function(me:MouseEvent) : Void {
if ( not isMediaShowing ) {
if ( not showDialog ) {
showDialog = true;
}
} else if ( toggleZoom != null ) {
toggleZoom();
}
}
The variable toggleZoom is defined in MobileWall.fx as follows:
package var toggleZoom: function() : Void;
toggleZoom is declared with package access so that it can be initialized from MobileMediaBrowser.fx, as shown next.
override var wall = MobileWall {
webSearch: bind yahoo
fullView: showMedia
hideMedia: hideMedia
isMediaShowing: bind ( media != null )
toggleZoom: bind media.toggleZoom
};
The variable media could be either an instance of Photo, an instance of Video, or null (if isMediaShowing is set to false). Because both Photo and Video derive from Media (as explained in Module 1 Task 4), the variable toggleZoom was added to Media.fx.
public-read protected var toggleZoom: function() : Void;
By using public-read protected as the access mode for the toggleZoom
variable, derived classes can override the variable and all other
classes can read the variable. Unless the variable is overridden, it
will have the value of null and will be treated as a no-op in the onMouseClicked function covered previously.
Swipe
A swipe, clicking the left mouse button and holding it as you move
the cursor across the screen, is a drag gesture that moves the wall of
thumbnails displayed on the mobile device screen forward or back one
"page" at a time. The swipe implementation is based on the scroll
control that was introduced in Module 2 Task 2. Swipe, and the flick scrolling function that was introduced in Module 4 Task 1, are similar in that they both manipulate ScrollControl's position variable, which is tied to the translation transformation of Wall.
You must override flick to implement the swipe function and that
requires that the ScrollControl's mouse handling be overridden. In MobileWall.fx, the Wall's scrollCtl variable is overridden so that different mouse handlers can be referenced.
override var scrollCtl = ScrollControl {
translateX: 0
translateY: Constants.TITLE_BAR_FACADE_HEIGHT
contentWidth: bind thumbsGroupWidth + Globals.THUMB_HORIZONTAL_SPACING
windowWidth: bind Globals.STAGE_WIDTH
windowHeight: bind Globals.STAGE_HEIGHT - Constants.TITLE_BAR_FACADE_HEIGHT
position: bind scrollPosition with inverse;
fractionDisplayed: bind fractionDisplayed;
scrollOffset: bind scrollOffset;
columns: bind wallColumns()
visible: bind fractionDisplayed < 1.0
// ScrollControl overrides the onMouseXXX functions. Here, we
// initialize them to functions in MobileWall. This trumps the
// override.
onMousePressed: handleMousePressed
onMouseDragged: handleMouseDragged
onMouseReleased: handleMouseReleased
};
In the handleMousePressed function, the current value of the ScrollControl's position variable is saved in posAnchor. The variable swiped is a flag that is set to true when the mouse has been dragged far enough for the drag gesture to meet the definition of a swipe.
function handleMousePressed(me : MouseEvent ) : Void {
posAnchor = scrollCtl.position;
swiped = false;
}
The handleMouseDragged function determines whether the drag gesture is a swipe. If the distance of the mouse movement is less than three-fifths of Globals.STAGE_WIDTH, the wall is dragged. If the distance of the mouse movement is greater than three-fifths of Globals.STAGE_WIDTH, the gesture is defined as a swipe and an animation moves the wall forward or back by one screen width.
function handleMouseDragged(me : MouseEvent ) : Void {
var dragDistance = if ( not Globals.ROTATE_MODE ) me.dragX else -me.dragY;
if ( not swiped ) {
// ignore small movements of the mouse
if (java.lang.Math.abs(dragDistance) > Constants.FLICK_DRAG_THRESHOLD) {
// see ScrollControl for treatement of the dragging flag.
scrollCtl.dragging = true;
// scroll the wall only so far...
if (java.lang.Math.abs(dragDistance) < Globals.STAGE_WIDTH * 3 / 5) {
scrollCtl.position =
posAnchor - dragDistance /
(scrollCtl.contentWidth - scrollCtl.windowWidth);
} else {
// the wall has be scrolled far enough to be a swipe...
// setting swiped to true causes subsequent mouse drag
// events to be ignored.
swiped = true;
// Move the scroll position forward by one "page"
var deltaPos = (numVisibleColumns() * columnWidth) /
(thumbsGroupWidth - maxVisibleWidth);
if ( dragDistance < 0 ) {
deltaPos *= -1
}
// notice that the new position is based on where the
// position was before the wall was dragged.
var newPos = posAnchor - deltaPos;
if ( newPos > 1.0 ) {
newPos = 1.0
}
else if ( newPos < 0.0 ) {
newPos = 0.0
}
// This animation moves the wall from the current
// scroll position to the new scroll position. The
// Interpolator.EASEIN causes the animation to accelerate
// over time.
Timeline {
keyFrames: [
at(500ms) {
scrollCtl.position => newPos tween Interpolator.EASEIN
}
]
}.play();
}
}
}
}
The handleMouseReleased function simply plays an animation that returns the ScrollControl's position variable back to its original value that was stored in posAnchor.
function handleMouseReleased(me : MouseEvent) : Void {
// see ScrollControl for treatment of the dragging flag.
scrollCtl.dragging = false;
// If the wall wasn't dragged far enough to be a swipe, then
// play an animation that repositions the wall back to where it was.
if ( not swiped ) {
Timeline {
keyFrames: [
at(750ms) {
scrollCtl.position => posAnchor tween Interpolator.EASEIN
}
]
}.play();
}
}
When the wall is moved, the number of visible columns is used to determine how much to increment (or decrement) ScrollControl's position
variable. For this code to work, the total width of the visible columns
must equal the width of the screen. The easiest way to ensure that the
columns fit the width of the screen is to scale the thumbnails to fit.
The variable thumbsScaleFactor gives the ratio of the screen width to the width of the visible columns.
def thumbsScaleFactor: Number =
bind Globals.STAGE_WIDTH / (numVisibleColumns() * columnWidth);
This scale factor is used to scale the thumbs Group by applying a scale transform to the thumbs Group. Note that the scale factor in the transformation is applied in both the x and y axis so that the thumbnails are scaled proportionately. You might also notice that the transforms sequence also includes a translate transformation to center the thumbs Group vertically. The same effect can be achieved by using TranslateX and TranslateY directly.
protected def thumbs: Group = Group {
content: bind thumbnails
transforms: bind [
javafx.scene.transform.Transform.scale(thumbsScaleFactor, thumbsScaleFactor)
javafx.scene.transform.Transform.translate(scrollOffset, thumbsTranslateY)
]
}
Try It
The file Photo.fx contains code that enables the zoom and pan functions. The magnification of a selected graphic is defined here.
- Try commenting out the division of
bounds.widthandbounds.heightbyzoomFactorinRectangle2D, in thetoggleZoomfunction. See the following code sample.
Source Codefunction toggleZoom() { if (zoomFactor == 1) { zoomFactor = 2; def bounds = imageView.getBoundsInScene(); panX = bounds.width / zoomFactor / 2; panY = bounds.height / zoomFactor / 2; var rect = Rectangle2D { minX: panX; minY: panY; width: bounds.width; /* / zoomFactor; */ height: bounds.height; /* / zoomFactor; */ } imageView.scaleX = zoomFactor; imageView.scaleY = zoomFactor; imageView.x = rect.minX; imageView.y = rect.minY; imageView.viewport = rect; } else { zoomFactor = 1; imageView.scaleX = 1; imageView.scaleY = 1; imageView.x = 0; imageView.y = 0; imageView.viewport = null; } } - Try commenting out the bounds checks from
panXandpanYin thepanfunction. See the following code sample.
Source Codefunction pan(dx: Number, dy: Number): Void { if (zoomFactor == 1) { return; } def viewW = imageView.getBoundsInScene().width; def viewH = imageView.getBoundsInScene().height; def zoomW = viewW / zoomFactor; def zoomH = viewH / zoomFactor; panX += dx; panY += dy; /* if (panX < 0) { panX = 0; } if (panY < 0) { panY = 0; } if (panX + zoomW > viewW) { panX = viewW - zoomW; } if (panY + zoomH > viewH) { panY = viewH - zoomH; } */ imageView.viewport = Rectangle2D { minX: panX minY: panY width: zoomW; height: zoomH; }; }
We welcome your participation in our community. Please keep your comments civil and on point. You may optionally provide your email address to be notified of repliesyour information is not used for any other purpose. By submitting a comment, you agree to these Terms of Use.
