This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit bcf37c2a15fd4b46997b74c9ed0d99f8ee864d2a
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Fri Sep 19 19:36:40 2025 +0200

    Bug fix: `GridView` in the JavaFX application was not showing all values.
    This is a rewrite of the `GridViewSkin` class which was drawing the cells.
    The new implementation draws the cells itself instead of relying on Table.
---
 netbeans-project/nbproject/project.xml             |   1 +
 .../org/apache/sis/gui/coverage/CellFormat.java    |  63 +-
 .../main/org/apache/sis/gui/coverage/GridCell.java |  70 --
 .../org/apache/sis/gui/coverage/GridControls.java  |   8 +-
 .../org/apache/sis/gui/coverage/GridError.java     |  19 +-
 .../main/org/apache/sis/gui/coverage/GridRow.java  | 110 ---
 .../org/apache/sis/gui/coverage/GridRowSkin.java   | 136 ---
 .../main/org/apache/sis/gui/coverage/GridTile.java |  43 +-
 .../main/org/apache/sis/gui/coverage/GridView.java | 253 +++---
 .../org/apache/sis/gui/coverage/GridViewSkin.java  | 944 +++++++++++----------
 .../main/org/apache/sis/gui/internal/Styles.java   |  17 +-
 .../org/apache/sis/gui/coverage/GridViewApp.java   |   6 +-
 12 files changed, 705 insertions(+), 965 deletions(-)

diff --git a/netbeans-project/nbproject/project.xml 
b/netbeans-project/nbproject/project.xml
index b642dd80ff..558c4502b4 100644
--- a/netbeans-project/nbproject/project.xml
+++ b/netbeans-project/nbproject/project.xml
@@ -35,6 +35,7 @@
             <word>geospatial</word>
             <word>Molodensky</word>
             <word>namespace</word>
+            <word>programmatically</word>
             <word>transformative</word>
             <word>untiled</word>
         </spellchecker-wordlist>
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java
index 6868d5b765..8d1b562306 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/CellFormat.java
@@ -38,7 +38,7 @@ import org.apache.sis.gui.internal.RecentChoices;
 /**
  * Formatter for cell values with a number of fraction digits determined from 
the sample value resolution.
  * The property value is the localized format pattern as produced by {@link 
DecimalFormat#toLocalizedPattern()}.
- * This property is usually available but not always; see {@link 
#hasPattern()}.
+ * This property is usually available but not always, see {@link 
#hasPattern()}.
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
@@ -51,9 +51,9 @@ final class CellFormat extends SimpleStringProperty {
     /**
      * The "classic" number format pattern (as opposed to scientific 
notation). This is non-null only after
      * {@link #cellFormat} switched to scientific notation and is used for 
switching back to classic notation.
-     * This is a workaround for the absence of 
`DecimalFormat.useScientificNotation(boolean)` method.
+     * This is a workaround for the absence of {@code 
DecimalFormat.useScientificNotation(boolean)} method.
      */
-    @Workaround(library="JDK", version="13")
+    @Workaround(library="JDK", version="24")
     private String classicFormatPattern;
 
     /**
@@ -95,6 +95,11 @@ final class CellFormat extends SimpleStringProperty {
      */
     boolean dataTypeIsInteger;
 
+    /**
+     * Whether the pattern has been made shorter by calls to {@link 
#shorterPattern()}.
+     */
+    private boolean shortenedPattern;
+
     /**
      * Temporarily set to {@code true} when the user selects or enters a new 
pattern in a GUI control, then
      * reset to {@code false} after the new values has been set. This is a 
safety against recursive calls
@@ -142,10 +147,46 @@ final class CellFormat extends SimpleStringProperty {
      */
     @Override
     public void setValue(final String pattern) {
+        shortenedPattern = false;
         if (cellFormat instanceof DecimalFormat) {
             ((DecimalFormat) cellFormat).applyLocalizedPattern(pattern);
             updatePropertyValue();
-            ((GridView) getBean()).contentChanged(false);
+            ((GridView) getBean()).updateCellValues();
+        }
+    }
+
+    /**
+     * Tries to reduce the size of the pattern used for formatting the numbers.
+     * This method is invoked when the numbers are too large for the cell 
width.
+     *
+     * @return whether the pattern has been made smaller.
+     */
+    final boolean shorterPattern() {
+        int n = cellFormat.getMaximumFractionDigits() - 1;
+        if (n >= 0) {
+            cellFormat.setMaximumFractionDigits(n);
+        } else if (cellFormat.isGroupingUsed()) {
+            cellFormat.setGroupingUsed(false);
+        } else {
+            return false;
+        }
+        shortenedPattern = true;
+        formatCell(lastValue);
+        return true;
+    }
+
+    /**
+     * Restores the pattern to the value specified in the pattern property.
+     * This method cancels the effect of {@link #shorterPattern()}.
+     * This method should be invoked when the cells have been made wider,
+     * and this change may allow the original pattern to fit in the new size.
+     */
+    final void restorePattern() {
+        if (shortenedPattern) {
+            shortenedPattern = false;
+            if (cellFormat instanceof DecimalFormat) {
+                ((DecimalFormat) cellFormat).applyLocalizedPattern(getValue());
+            }
         }
     }
 
@@ -199,7 +240,7 @@ final class CellFormat extends SimpleStringProperty {
          */
         final int min = cellFormat.getMinimumFractionDigits();
         final int max = cellFormat.getMaximumFractionDigits();
-        final String[] patterns = new String[max + 2];
+        final var patterns = new String[max + 2];
         patterns[max + 1] = getValue();
         cellFormat.setMinimumFractionDigits(max);
         for (int n=max; n >= 0; n--) {
@@ -211,7 +252,7 @@ final class CellFormat extends SimpleStringProperty {
         /*
          * Create the combo-box with above patterns and register listeners in 
both directions.
          */
-        final ComboBox<String> choices = new ComboBox<>();
+        final var choices = new ComboBox<String>();
         choices.setEditable(true);
         choices.getItems().setAll(patterns);
         choices.getSelectionModel().selectFirst();
@@ -222,7 +263,7 @@ final class CellFormat extends SimpleStringProperty {
 
     /**
      * Invoked when the {@link #cellFormat} configuration needs to be updated.
-     * Callers should invoke {@link GridView#contentChanged(boolean)} after 
this method.
+     * Callers should invoke {@link GridView#updateCellValues()} after this 
method.
      *
      * @param  image  the source image (shall be non-null).
      * @param  band   index of the band to show in this grid view.
@@ -256,7 +297,7 @@ final class CellFormat extends SimpleStringProperty {
 
     /**
      * Returns the number of fraction digits to use for formatting sample 
values in the given band of the given image.
-     * This method use the {@value PlanarImage#SAMPLE_RESOLUTIONS_KEY} 
property value.
+     * This method uses the {@value PlanarImage#SAMPLE_RESOLUTIONS_KEY} 
property value.
      *
      * @param  image  the image from which to get the number of fraction 
digits.
      * @param  band   the band for which to get the number of fraction digits.
@@ -276,9 +317,11 @@ final class CellFormat extends SimpleStringProperty {
     }
 
     /**
-     * Get the desired sample value from the specified tile and formats its 
string representation.
+     * Gets the desired sample value from the specified tile and formats its 
string representation.
      * As a slight optimization, we reuse the previous string representation 
if the number is the same.
      * It may happen in particular with fill values.
+     *
+     * @throws IndexOutOfBoundsException if the given pixel coordinates are 
out of bounds.
      */
     final String format(final Raster tile, final int x, final int y, final int 
b) {
         buffer.setLength(0);
@@ -286,7 +329,7 @@ final class CellFormat extends SimpleStringProperty {
             final int  integer = tile.getSample(x, y, b);
             final double value = integer;
             if (Double.doubleToRawLongBits(value) != 
Double.doubleToRawLongBits(lastValue)) {
-                // The `format` method invoked here is not the same as in 
`double` case.
+                // The `format` method invoked here is not the same as in the 
`double` case.
                 lastValueAsText = cellFormat.format(integer, buffer, 
formatField).toString();
                 lastValue = value;
             }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridCell.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridCell.java
deleted file mode 100644
index e4b50c4149..0000000000
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridCell.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.sis.gui.coverage;
-
-import javafx.scene.control.IndexedCell;
-import javafx.scene.control.Skin;
-import javafx.scene.control.skin.CellSkinBase;
-
-
-/**
- * A single cell in a {@link GridRow}. This cell contains one sample value of 
one pixel in an image.
- *
- * @author  Martin Desruisseaux (Geomatys)
- */
-final class GridCell extends IndexedCell<String> {
-    /**
-     * Creates a new cell.
-     */
-    GridCell() {
-        /*
-         * In unmanaged mode, the parent (GridRow) will ignore the cell 
preferred size computations and layout.
-         * Changes in layout bounds will not trigger relayout above it. This 
is what we want since the parents
-         * decide themselves when to layout in our implementation.
-         */
-        setManaged(false);
-    }
-
-    /**
-     * Sets the sample value to show in this grid cell.
-     * Note that the {@code value} may be null even if {@code empty} is false.
-     * It may happen if the image is still loading in a background thread.
-     *
-     * @param  value  the sample value, or {@code null} if not available.
-     * @param  empty  whether this cell is used for filling empty space
-     *                (not to be confused with value not yet available).
-     */
-    @Override
-    protected void updateItem(String value, final boolean empty) {
-        super.updateItem(value, empty);
-        if (value == null) value = "";
-        setText(value);
-    }
-
-    /**
-     * Creates a new instance of the skin responsible for rendering this grid 
cell.
-     * From the perspective of {@link IndexedCell}, the {@link Skin} is a 
black box.
-     * It listens and responds to changes in state of this grid cell.
-     *
-     * @return the renderer of this grid cell.
-     */
-    @Override
-    protected Skin<GridCell> createDefaultSkin() {
-        // We have nothing to add compared to the base implementation.
-        return new CellSkinBase<>(this);
-    }
-}
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java
index fb6ffe5653..b0fb761481 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridControls.java
@@ -69,8 +69,8 @@ final class GridControls extends ViewAndControls {
         final VBox displayPane;
         {   // Block for making variables locale to this scope.
             final GridPane gp = Styles.createControlGrid(0,
-                label(vocabulary, Vocabulary.Keys.Width,  
createSlider(view.cellWidth,  30, 200)),
-                label(vocabulary, Vocabulary.Keys.Height, 
createSlider(view.cellHeight, 10,  50)),
+                label(vocabulary, Vocabulary.Keys.Width,  
createSlider(view.cellWidth)),
+                label(vocabulary, Vocabulary.Keys.Height, 
createSlider(view.cellHeight)),
                 label(vocabulary, Vocabulary.Keys.Format, 
view.cellFormat.createEditor()));
 
             Styles.setAllRowToSameHeight(gp);
@@ -92,8 +92,8 @@ final class GridControls extends ViewAndControls {
      * Creates a new slider for the given range of values and bound to the 
specified properties.
      * This is used for creating the sliders to show in the "Display" pane.
      */
-    private static Slider createSlider(final DoubleProperty property, final 
double min, final double max) {
-        final Slider slider = new Slider(min, max, property.getValue());
+    private static Slider createSlider(final DoubleProperty property) {
+        final var slider = new Slider(20, 120, property.getValue());
         property.bind(slider.valueProperty());
         slider.setShowTickMarks(false);
         return slider;
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java
index c17720cbb8..5bdc4fa079 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridError.java
@@ -21,6 +21,8 @@ import javafx.geometry.Pos;
 import javafx.geometry.Insets;
 import javafx.scene.control.Button;
 import javafx.scene.control.Label;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundFill;
 import javafx.scene.layout.TilePane;
 import javafx.scene.layout.VBox;
 import javafx.scene.paint.Color;
@@ -38,6 +40,11 @@ import org.apache.sis.util.resources.Vocabulary;
  * @author  Martin Desruisseaux (Geomatys)
  */
 final class GridError extends VBox {
+    /**
+     * The background for error boxes.
+     */
+    private static final Background BACKGROUND = new Background(new 
BackgroundFill(Color.FLORALWHITE, null, null));
+
     /**
      * The tile in error.
      */
@@ -60,7 +67,7 @@ final class GridError extends VBox {
     private final Label message;
 
     /**
-     * The zero-based row and column indices of the tile.
+     * The row and column indices of the tile.
      * This is computed by {@link GridView#getTileBounds(int, int)} and should 
be constant.
      */
     private final Rectangle region;
@@ -75,9 +82,9 @@ final class GridError extends VBox {
         this.region    = view.getTileBounds(tile.tileX, tile.tileY);
         this.header    = Resources.format(Resources.Keys.CanNotFetchTile_2, 
tile.tileX, tile.tileY);
 
-        final Button   details = new 
Button(Vocabulary.format(Vocabulary.Keys.Details));
-        final Button   retry   = new 
Button(Vocabulary.format(Vocabulary.Keys.Retry));
-        final TilePane buttons = new TilePane(12, 0, details, retry);
+        final var details = new 
Button(Vocabulary.format(Vocabulary.Keys.Details));
+        final var retry   = new 
Button(Vocabulary.format(Vocabulary.Keys.Retry));
+        final var buttons = new TilePane(12, 0, details, retry);
         buttons.setPrefRows(1);
         buttons.setPrefColumns(2);
         buttons.setAlignment(Pos.CENTER);
@@ -96,6 +103,7 @@ final class GridError extends VBox {
         setPadding(new Insets(12, 18, 24, 18));
         details.setOnAction((e) -> showDetails());
         retry  .setOnAction((e) -> retry());
+        setBackground(BACKGROUND);
     }
 
     /**
@@ -120,8 +128,9 @@ final class GridError extends VBox {
      * Invoked when the user asked to retry a tile computation.
      */
     private void retry() {
-        final GridView view = (GridView) getParent();
+        final var view = (GridView) getParent();
         ((GridViewSkin) view.getSkin()).removeError(this);
         tile.clear();
+        view.requestLayout();
     }
 }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRow.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRow.java
deleted file mode 100644
index 1ee9294ecf..0000000000
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRow.java
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.sis.gui.coverage;
-
-import java.awt.image.RenderedImage;
-import javafx.scene.control.IndexedCell;
-import javafx.scene.control.Skin;
-import javafx.scene.control.skin.VirtualFlow;
-import javafx.scene.text.Font;
-import javafx.scene.text.FontWeight;
-
-
-/**
- * A row in a {@link RenderedImage}. This is only a pointer to a row of pixels 
in an image,
- * not a storage for pixel values. The row to be shown is identified by {@link 
#getIndex()},
- * which is a zero-based index. Note that <var>y</var> coordinates in a {@link 
RenderedImage}
- * do not necessarily starts at 0, so a constant offset may exist between 
{@link #getIndex()}
- * values and image <var>y</var> coordinates.
- *
- * <p>{@link GridRow} instances are created by JavaFX {@link VirtualFlow}, 
which is responsible
- * for reusing cells. A relatively small number of {@code GridRow} instances 
should be created
- * even if the image contains millions of rows.</p>
- *
- * <p>The {@code GridRow} index value is zero-based. This is not necessarily 
the <var>y</var> coordinate
- * in the image since {@link RenderedImage} coordinate system do not 
necessarily starts at zero.
- * This value may be outside image bounds, in which case this {@code GridRow} 
should be rendered as empty.
- *
- * @author  Martin Desruisseaux (Geomatys)
- */
-final class GridRow extends IndexedCell<Void> {
-    /**
-     * The grid view where this row will be shown.
-     */
-    final GridView view;
-
-    /**
-     * The <var>y</var> coordinate of the tile in the {@link RenderedImage}.
-     * Note that those coordinates do not necessarily start at zero; negative 
values may be valid.
-     * This value is computed from {@link #getIndex()} value and cached for 
efficiency.
-     */
-    private int tileY;
-
-    /**
-     * Invoked by {@link VirtualFlow} when a new cell is needed.
-     * This constructor is referenced by lambda-function in {@link 
GridViewSkin}.
-     */
-    GridRow(final VirtualFlow<GridRow> owner) {
-        view = (GridView) owner.getParent();
-        setPrefWidth(view.getContentWidth());
-        setFont(Font.font(null, FontWeight.BOLD, -1));      // Apply only to 
the header column.
-        setOnMouseMoved((GridViewSkin) view.getSkin());
-        setManaged(false);
-    }
-
-    /**
-     * Invoked when this {@code GridRow} is used for showing a new image row.
-     * We override this method as an alternative to registering a listener to
-     * {@link #indexProperty()} (for reducing the number of object 
allocations).
-     *
-     * @param  row  index of the new row.
-     */
-    @Override
-    public void updateIndex(final int row) {
-        super.updateIndex(row);
-        tileY = view.toTileY(row);
-        final Skin<?> skin = getSkin();
-        if (skin != null) {
-            ((GridRowSkin) skin).setRowIndex(row);
-        }
-        updateItem(null, row < 0 || row >= view.getImageHeight());
-    }
-
-    /**
-     * Returns the sample value in the given column of this row. If the tile 
is not available at the time
-     * this method is invoked, then the tile will loaded in a background 
thread and the grid view will be
-     * refreshed when the tile become available.
-     *
-     * @param  column  zero-based <var>x</var> coordinate of sample to get 
(may differ from image coordinate).
-     * @return the sample value in the specified column, or {@code null} if 
not yet available.
-     */
-    final String getSampleValue(final int column) {
-        return view.getSampleValue(tileY, getIndex(), column);
-    }
-
-    /**
-     * Creates a new instance of the skin responsible for rendering this grid 
row.
-     * From the perspective of {@link IndexedCell}, the {@link Skin} is a 
black box.
-     * It listens and responds to changes in state of this grid row.
-     *
-     * @return the renderer of this grid row.
-     */
-    @Override
-    protected Skin<GridRow> createDefaultSkin() {
-        return new GridRowSkin(this);
-    }
-}
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRowSkin.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRowSkin.java
deleted file mode 100644
index 32788075a6..0000000000
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridRowSkin.java
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.sis.gui.coverage;
-
-import java.util.List;
-import java.util.ArrayList;
-import javafx.scene.Node;
-import javafx.scene.text.Text;
-import javafx.scene.control.skin.CellSkinBase;
-import javafx.collections.ObservableList;
-import javafx.geometry.Pos;
-
-
-/**
- * The renderer of {@link GridRow} instances. On construction, this object 
contains only one child.
- * That child is an instance of {@link javafx.scene.text.Text} and is used for 
the row header. All
- * other children will be instances of {@link GridCell} created and removed as 
needed during the
- * layout pass.
- *
- * @author  Martin Desruisseaux (Geomatys)
- */
-final class GridRowSkin extends CellSkinBase<GridRow> {
-    /**
-     * Invoked by {@link GridRow#createDefaultSkin()}.
-     */
-    GridRowSkin(final GridRow owner) {
-        super(owner);
-        setRowIndex(owner.getIndex());
-    }
-
-    /**
-     * Invoked when the index to show in the header column changed.
-     */
-    final void setRowIndex(final int index) {
-        final Text header = (Text) getChildren().get(0);
-        header.setText(getSkinnable().view.formatHeaderValue(index, true));
-    }
-
-    /**
-     * Invoked during the layout pass to position the cells to be rendered by 
this row.
-     * This method also sets the content of the cell.
-     *
-     * The {@code width} argument can be a large number (for example 24000) 
because it includes
-     * the area outside the view. In order to avoid creating a large number of 
{@link GridCell}
-     * instances, this method have to find the current view port area and 
render only the cells
-     * in that area. We do not have to do that vertically because the vertical 
virtualization
-     * is done by {@link GridViewSkin} parent class.
-     *
-     * <h4>Implementation note</h4>
-     * I'm not sure it is a good practice to add/remove children and to modify 
text values here,
-     * but I have not identified another place yet. However, the JavaFX 
implementation of table
-     * skin seems to do the same, so I presume it is okay.
-     *
-     * @param  x       the <var>x</var> position of this row, usually 0.
-     * @param  y       the <var>y</var> position of this row, usually 0 (this 
is a relative position).
-     * @param  width   width of the row, including the area currently hidden 
because out of view.
-     * @param  height  height of the region where to render this row (for 
example 16).
-     */
-    @Override
-    protected void layoutChildren(final double x, final double y, final double 
width, final double height) {
-        /*
-         * Do not invoke super.layoutChildren(…) since we are doing a 
different layout.
-         * The first child is a `javafx.scene.text.Text`, which we use for row 
header.
-         */
-        final ObservableList<Node> children = getChildren();
-        final GridRow row = getSkinnable();
-        final GridViewSkin layout = (GridViewSkin) row.view.getSkin();
-        /*
-         * Set the position of the header cell, but not its content. The 
content has been set by
-         * `setRowIndex(int)` and does not need to be recomputed even during 
horizontal scroll.
-         */
-        double pos = layout.leftPosition;               // Horizontal position 
in the virtual view.
-        ((Text) children.get(0)).resizeRelocate(pos, y, layout.headerWidth, 
height);
-        pos += layout.headerWidth;
-        /*
-         * Get the beginning (pos) and end (limit) of the region to render. We 
create only the amount
-         * of GridCell instances needed for rendering this region. We should 
not create cells for the
-         * whole row since it would be too many cells (can be millions). 
Instead, we recycle the cells
-         * in a list of children that we try to keep small. All children 
starting at index 1 shall be
-         * GridCell instances created in this method.
-         */
-        final double cellWidth  = layout.cellWidth;             // Includes 
the cell spacing.
-        final double available  = layout.cellInnerWidth;
-        final double limit      = layout.rightPosition;         // Horizontal 
position where to stop.
-        int          column     = layout.firstVisibleColumn;    // Zero-based 
column index in image.
-        int          childIndex = 0;
-        List<GridCell> newChildren = null;
-        final int count = children.size();
-        while (pos < limit) {
-            /*
-             * For sample value, we need to recompute both the values and the 
position. Note that even if
-             * the cells appear at the same positions visually (with different 
content), they moved in the
-             * virtual flow if some scrolling occurred.
-             */
-            final GridCell cell;
-            if (++childIndex < count) {
-                cell = (GridCell) children.get(childIndex);
-            } else {
-                cell = new GridCell();
-                cell.setAlignment(Pos.CENTER_RIGHT);
-                if (newChildren == null) {
-                    newChildren = new ArrayList<>(1 + (int) ((limit - pos) / 
cellWidth));
-                }
-                newChildren.add(cell);
-            }
-            final String value = row.getSampleValue(column++);
-            cell.updateItem(value, value == GridView.OUT_OF_BOUNDS);           
 // Identity comparison is okay here.
-            cell.resizeRelocate(pos, 0, available, height);
-            pos += cellWidth;
-        }
-        /*
-         * Add or remove fields only at the end of this method in order to 
fire only one change event.
-         * It is important to remove unused fields not only for saving memory, 
but also for preventing
-         * those fields to appear at random positions in the rendered region.
-         */
-        if (newChildren != null) {
-            children.addAll(newChildren);
-        } else if (++childIndex < count) {
-            children.remove(childIndex, count);
-        }
-    }
-}
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java
index d599609e26..be5cea1736 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridTile.java
@@ -23,8 +23,8 @@ import org.apache.sis.gui.internal.BackgroundThreads;
 
 
 /**
- * A {@link Raster} for a {@link RenderedImage} tile,
- * potentially fetched (loaded or computed) in a background thread.
+ * A single tile potentially fetched (loaded or computed) from an image in a 
background thread.
+ * The source is a {@link RenderedImage} and the cached object is {@link 
Raster}.
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
@@ -34,13 +34,6 @@ final class GridTile {
      */
     final int tileX, tileY;
 
-    /**
-     * Hash code value computed from tile indices only. Other fields must be 
ignored.
-     *
-     * @see #hashCode()
-     */
-    private final int hashCode;
-
     /**
      * The tile, or {@code null} if not yet fetched.
      *
@@ -62,30 +55,30 @@ final class GridTile {
     /**
      * Creates a new tile for the given tile coordinates.
      */
-    GridTile(final int tileX, final int tileY, final int numXTiles) {
+    GridTile(final int tileX, final int tileY) {
         this.tileX = tileX;
         this.tileY = tileY;
-        hashCode = tileX + tileY * numXTiles;
     }
 
     /**
-     * Returns a hash code value for this tile. This hash code value must be 
based only on tile indices;
-     * the {@link #tile} and the {@link #error} must be ignored, because we 
will use {@link GridTile}
+     * Returns a hash code value for this tile. This hash code value must be 
based only on tile indices.
+     * The {@link #tile} and the {@link #error} must be ignored, because we 
will use {@link GridTile}
      * instances also as keys for locating tiles in a hash map.
      */
     @Override
     public int hashCode() {
-        return hashCode;
+        return tileX ^ Integer.reverse(tileY);
     }
 
     /**
      * Compares the indices of this tile with the given object for equality.
-     * Only indices are compared; the raster is ignored. See {@link 
#hashCode()} for more information.
+     * Only indices are compared, the raster is ignored.
+     * See {@link #hashCode()} for more information.
      */
     @Override
     public boolean equals(final Object other) {
         if (other instanceof GridTile) {
-            final GridTile that = (GridTile) other;
+            final var that = (GridTile) other;
             return tileX == that.tileX && tileY == that.tileY;
             // Intentionally no other comparisons.
         }
@@ -147,8 +140,11 @@ final class GridTile {
             loading = true;
             final RenderedImage image = view.getImage();
             BackgroundThreads.execute(new Task<Raster>() {
-                /** Invoked in background thread for fetching the tile. */
-                @Override protected Raster call() {
+                /**
+                 * Invoked in background thread for fetching the tile.
+                 */
+                @Override
+                protected Raster call() {
                     return image.getTile(tileX, tileY);
                 }
 
@@ -157,11 +153,12 @@ final class GridTile {
                  * the same image, it will be informed that the tile is 
available. Otherwise
                  * (if the image has changed) we ignore the result.
                  */
-                @Override protected void succeeded() {
+                @Override
+                protected void succeeded() {
                     clear();
                     if (view.getImage() == image) {
                         tile = getValue();
-                        view.contentChanged(false);
+                        view.updateCellValues();
                     }
                 }
 
@@ -169,7 +166,8 @@ final class GridTile {
                  * Invoked in JavaFX thread on failure. Discards everything 
and sets the error message
                  * if {@link GridView} is still showing the image for which we 
failed to load a tile.
                  */
-                @Override protected void failed() {
+                @Override
+                protected void failed() {
                     clear();
                     if (view.getImage() == image) {
                         error = new GridError(view, GridTile.this, 
getException());
@@ -182,7 +180,8 @@ final class GridTile {
                  * Ideally we should interrupt the {@link 
RenderedImage#getTile(int, int)}
                  * process, but we currently have no API for that.
                  */
-                @Override protected void cancelled() {
+                @Override
+                protected void cancelled() {
                     clear();
                 }
             });
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java
index a2670c23d8..0246522e43 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridView.java
@@ -32,10 +32,12 @@ import javafx.beans.property.SimpleObjectProperty;
 import javafx.beans.property.StringProperty;
 import javafx.concurrent.Task;
 import javafx.scene.control.Control;
+import javafx.scene.control.ScrollBar;
 import javafx.scene.control.Skin;
 import javafx.scene.paint.Color;
 import javafx.scene.paint.Paint;
 import org.apache.sis.util.ArgumentChecks;
+import org.apache.sis.util.privy.Numerics;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.coverage.grid.GridGeometry;
 import org.apache.sis.coverage.grid.GridCoverage;
@@ -43,7 +45,6 @@ import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.GridCoverageResource;
 import org.apache.sis.gui.internal.BackgroundThreads;
 import org.apache.sis.gui.internal.LogHandler;
-import org.apache.sis.gui.internal.Styles;
 import org.apache.sis.gui.internal.ExceptionReporter;
 import org.apache.sis.image.DataType;
 
@@ -54,12 +55,10 @@ import org.apache.sis.image.DataType;
  * The view shows one band at a time, but the band to show can be changed 
(thus providing a navigation in a third
  * dimension).
  *
- * <p>This class is designed for large images, with tiles loaded in a 
background thread only when first needed.
- * This is not a general purpose grid viewer; for matrices of relatively small 
size (e.g. less than 100 columns),
- * consider using the standard JavaFX {@link javafx.scene.control.TableView} 
instead.</p>
+ * <p>This class is designed for large images, with tiles loaded in a 
background thread only when first needed.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.5
  *
  * @see CoverageExplorer
  *
@@ -67,18 +66,6 @@ import org.apache.sis.image.DataType;
  */
 @DefaultProperty("image")
 public class GridView extends Control {
-    /**
-     * Minimum cell width and height. Must be greater than zero, otherwise 
infinite loops may happen.
-     *
-     * @see #getSizeValue(DoubleProperty)
-     */
-    static final int MIN_CELL_SIZE = 1;
-
-    /**
-     * The string value for sample values that are out of image bounds.
-     */
-    static final String OUT_OF_BOUNDS = "";
-
     /**
      * If a loading is in progress, the loading process. Otherwise {@code 
null}.
      */
@@ -96,8 +83,10 @@ public class GridView extends Control {
 
     /**
      * Information copied from {@link #imageProperty} for performance.
+     * Those values are used only for checking if a coordinate is inside image 
bounds.
+     * The maximum coordinates are exclusive.
      */
-    private int width, height, minX, minY, numXTiles;
+    private int minX, minY, maxX, maxY;
 
     /**
      * Information copied from {@link #imageProperty} for performance.
@@ -106,17 +95,16 @@ public class GridView extends Control {
     private int tileWidth, tileHeight;
 
     /**
-     * Information copied and adjusted from {@link #imageProperty} for 
performance. Values are adjusted for using
-     * zero-based indices as expected by JavaFX tables (by contrast, pixel 
indices in a {@link RenderedImage} may
-     * start at a non-zero value).
+     * Information copied from {@link #imageProperty} for performance.
      */
     private int tileGridXOffset, tileGridYOffset;
 
     /**
-     * A cache of most recently used {@link #imageProperty} tiles. We use a 
very simple caching mechanism here,
-     * keeping the most recently used tiles up to 10 Mb of memory. We do not 
need more sophisticated mechanism
-     * since "real" caching is done by {@link 
org.apache.sis.image.ComputedImage}. The purpose of this cache is
-     * to remember that a tile is immediately available and that we do not 
need to start a background thread.
+     * A cache of most recently used {@link #imageProperty} tiles.
+     * We use a simple caching mechanism, keeping the most recently used tiles 
up to some maximal amount of memory.
+     * No need for something more advanced because the real cache is done by 
{@link org.apache.sis.image.ComputedImage}.
+     * The purpose of this cache is to remember that a tile is immediately 
available and that we do not need to start
+     * a background thread.
      */
     private final GridTileCache tiles;
 
@@ -142,7 +130,7 @@ public class GridView extends Control {
      * It shall be a number strictly greater than zero.
      *
      * <h4>API note</h4>
-     * We do not provide getter/setter for this property; use {@link 
DoubleProperty#set(double)}
+     * We do not provide getter/setter for this property, use {@link 
DoubleProperty#set(double)}
      * directly instead. We omit the "Property" suffix for making this 
operation more natural.
      */
     public final DoubleProperty headerWidth;
@@ -153,7 +141,7 @@ public class GridView extends Control {
      * It shall be a number strictly greater than zero.
      *
      * <h4>API note</h4>
-     * We do not provide getter/setter for this property; use {@link 
DoubleProperty#set(double)}
+     * We do not provide getter/setter for this property, use {@link 
DoubleProperty#set(double)}
      * directly instead. We omit the "Property" suffix for making this 
operation more natural.
      */
     public final DoubleProperty cellWidth;
@@ -163,7 +151,7 @@ public class GridView extends Control {
      * It shall be a number strictly greater than zero.
      *
      * <h4>API note</h4>
-     * We do not provide getter/setter for this property; use {@link 
DoubleProperty#set(double)}
+     * We do not provide getter/setter for this property, use {@link 
DoubleProperty#set(double)}
      * directly instead. We omit the "Property" suffix for making this 
operation more natural.
      */
     public final DoubleProperty cellHeight;
@@ -174,7 +162,7 @@ public class GridView extends Control {
      * {@linkplain #cellHeight cell height} should be sufficient.
      *
      * <h4>API note</h4>
-     * We do not provide getter/setter for this property; use {@link 
DoubleProperty#set(double)}
+     * We do not provide getter/setter for this property, use {@link 
DoubleProperty#set(double)}
      * directly instead. We omit the "Property" suffix for making this 
operation more natural.
      */
     public final DoubleProperty cellSpacing;
@@ -183,7 +171,7 @@ public class GridView extends Control {
      * The background color of row and column headers.
      *
      * <h4>API note</h4>
-     * We do not provide getter/setter for this property; use {@link 
ObjectProperty#set(Object)}
+     * We do not provide getter/setter for this property, use {@link 
ObjectProperty#set(Object)}
      * directly instead. We omit the "Property" suffix for making this 
operation more natural.
      */
     public final ObjectProperty<Paint> headerBackground;
@@ -204,13 +192,13 @@ public class GridView extends Control {
     final CellFormat cellFormat;
 
     /**
-     * If this grid view is associated with controls, the controls. Otherwise 
{@code null}.
-     * This is used only for notifications; a future version may use a more 
generic listener.
+     * If this grid view is associated with controls, these controls. 
Otherwise {@code null}.
+     * This is used only for notifications. A future version may use a more 
generic listener.
      * We use this specific mechanism because there is no {@code 
coverageProperty} in this class.
      *
      * @see GridControls#notifyDataChanged(GridCoverageResource, GridCoverage)
      */
-    private final GridControls controls;
+    final GridControls controls;
 
     /**
      * Creates an initially empty grid view. The content can be set after
@@ -231,7 +219,7 @@ public class GridView extends Control {
         this.controls    = controls;
         bandProperty     = new BandProperty();
         imageProperty    = new SimpleObjectProperty<>(this, "image");
-        headerWidth      = new SimpleDoubleProperty  (this, "headerWidth", 60);
+        headerWidth      = new SimpleDoubleProperty  (this, "headerWidth", 80);
         cellWidth        = new SimpleDoubleProperty  (this, "cellWidth",   60);
         cellHeight       = new SimpleDoubleProperty  (this, "cellHeight",  20);
         cellSpacing      = new SimpleDoubleProperty  (this, "cellSpacing",  4);
@@ -250,6 +238,7 @@ public class GridView extends Control {
     /**
      * The property for selecting the band to show. This property verifies
      * the validity of given band argument before to modify the value.
+     * The expected value is a zero-based band index.
      *
      * @see #getBand()
      * @see #setBand(int)
@@ -269,7 +258,7 @@ public class GridView extends Control {
                 ArgumentChecks.ensurePositive("band", band);
             }
             super.set(band);
-            contentChanged(false);
+            updateCellValues();
         }
 
         /** Sets the band without performing checks, except ensuring that 
value is positive. */
@@ -292,7 +281,8 @@ public class GridView extends Control {
 
     /**
      * Sets the image to show in this table.
-     * This method shall be invoked from JavaFX thread and returns quickly; it 
does not attempt to fetch any tile.
+     * This method shall be invoked from the JavaFX thread.
+     * This method returns quickly, it does not attempt to fetch any tile.
      * Calls to {@link RenderedImage#getTile(int, int)} will be done in a 
background thread when first needed.
      *
      * @param  image  the image to show in this table, or {@code null} if none.
@@ -307,8 +297,8 @@ public class GridView extends Control {
     /**
      * Loads image in a background thread from the given source.
      * This method shall be invoked from JavaFX thread and returns immediately.
-     * The grid content may appear unmodified after this method returns;
-     * the modifications will appear after an undetermined amount of time.
+     * The grid content may appear unmodified after this method returns.
+     * The modifications will appear after an undetermined amount of time.
      *
      * @param  source  the coverage or resource to load, or {@code null} if 
none.
      *
@@ -467,24 +457,19 @@ public class GridView extends Control {
      * See {@link #setImage(RenderedImage)} for more description.
      *
      * @param  image  the new image to show. May be {@code null}.
-     * @throws ArithmeticException if the "tile grid x/y offset" property is 
too large.
+     * @throws ArithmeticException if the "max x/y" property is too large.
      */
     private void onImageSpecified(final RenderedImage image) {
         cancelLoader();
-        tiles.clear();          // Let garbage collector dispose the rasters.
+        tiles.clear();              // Let the garbage collector disposes the 
rasters.
         lastTile = null;
-        width    = 0;
-        height   = 0;
+        maxX = Integer.MIN_VALUE;   // A way to make sure that all coordinates 
are considered out of bounds.
+        maxY = Integer.MIN_VALUE;
         if (image != null) {
-            minX            = image.getMinX();
-            minY            = image.getMinY();
-            width           = image.getWidth();
-            height          = image.getHeight();
-            numXTiles       = image.getNumXTiles();
             tileWidth       = Math.max(1, image.getTileWidth());
             tileHeight      = Math.max(1, image.getTileHeight());
-            tileGridXOffset = Math.subtractExact(image.getTileGridXOffset(), 
minX);
-            tileGridYOffset = Math.subtractExact(image.getTileGridYOffset(), 
minY);
+            tileGridXOffset = image.getTileGridXOffset();
+            tileGridYOffset = image.getTileGridYOffset();
             cellFormat.dataTypeIsInteger = false;           // To be kept 
consistent with `cellFormat` pattern.
             final SampleModel sm = image.getSampleModel();
             if (sm != null) {                               // Should never be 
null, but we are paranoiac.
@@ -495,64 +480,71 @@ public class GridView extends Control {
                 cellFormat.dataTypeIsInteger = DataType.isInteger(sm);
             }
             cellFormat.configure(image, getBand());
+            // Set image bounds only after everything else succeeded.
+            minX = image.getMinX();
+            maxX = Math.addExact(minX, image.getWidth());
+            minY = image.getMinY();
+            maxY = Math.addExact(minY, image.getHeight());
         }
-        contentChanged(true);
-    }
-
-    /**
-     * Invoked when the content may have changed. If {@code all} is {@code 
true}, then everything
-     * may have changed including the number of rows and columns. If {@code 
all} is {@code false}
-     * then the number of rows and columns is assumed the same.
-     */
-    final void contentChanged(final boolean all) {
         final Skin<?> skin = getSkin();             // May be null if the view 
is not yet shown.
         if (skin instanceof GridViewSkin) {         // Could be a user 
instance (not recommended).
-            ((GridViewSkin) skin).contentChanged(all);
+            ((GridViewSkin) skin).clear();
         }
+        requestLayout();
     }
 
     /**
-     * Returns the width that this view would have if it was fully shown 
(without horizontal scroll bar).
-     * This value depends on the number of columns in the image and the size 
of each cell.
-     * This method does not take in account the space occupied by the vertical 
scroll bar.
-     */
-    final double getContentWidth() {
-        /*
-         * Add one more column for avoiding offsets caused by the rounding of 
scroll bar position to
-         * integer multiple of column size. The SCROLLBAR_WIDTH minimal value 
used below is arbitrary;
-         * we take a value close to the vertical scrollbar width as a safety.
-         */
-        final double w = getSizeValue(cellWidth);
-        return width * w + getSizeValue(headerWidth) + Math.max(w, 
Styles.SCROLLBAR_WIDTH);
-    }
-
-    /**
-     * Returns the value of the given property as a real number not smaller 
than {@value #MIN_CELL_SIZE}.
-     * We use this method instead of {@link Math#max(double, double)} because 
we want {@link Double#NaN}
-     * values to be replaced by {@value #MIN_CELL_SIZE}.
+     * Rewrites the cell values. This method can be invoked when the band to 
show has changed,
+     * or when a change is detected in a writable image. This method assumes 
that the image size
+     * has not changed.
      */
-    static double getSizeValue(final DoubleProperty property) {
-        final double value = property.get();
-        return (value >= MIN_CELL_SIZE) ? value : MIN_CELL_SIZE;
+    final void updateCellValues() {
+        final Skin<?> skin = getSkin();             // May be null if the view 
is not yet shown.
+        if (skin instanceof GridViewSkin) {         // Could be a user 
instance (not recommended).
+            ((GridViewSkin) skin).updateCellValues();
+        }
     }
 
     /**
-     * Returns the number of rows in the image. This is also the number of 
rows in the
-     * {@link GridViewSkin} virtual flow, which is using a vertical primary 
direction.
+     * Configures the given scroll bar.
      *
-     * @see javafx.scene.control.skin.VirtualContainerBase#getItemCount()
+     * @param  bar       the scroll bar to configure.
+     * @param  numCells  number of cells created by the caller (one more than 
the number of visible cells).
+     * @param  vertical  {@code true} if the scroll bar is vertical, or {@code 
false} if horizontal.
      */
-    final int getImageHeight() {
-        return height;
+    final void scaleScrollBar(final ScrollBar bar, int numCells, final boolean 
vertical) {
+        int min, max;
+        if (vertical) {
+            min = minY;
+            max = maxY;
+        } else {
+            min = minX;
+            max = maxX;
+        }
+        if (max > min) {
+            numCells = Math.max(1, Math.min(numCells - 2, max));
+            max -= numCells;
+            bar.setMin(min);
+            bar.setMax(max);
+            bar.setVisibleAmount(numCells);
+            double value = bar.getValue();
+            if (value < min) {
+                value = min;
+            } else if (value > max) {
+                value = max;
+            } else {
+                return;
+            }
+            bar.setValue(value);
+        }
     }
 
     /**
      * Returns the bounds of a single tile in the image. This method is 
invoked only
      * if an error occurred during {@link RenderedImage#getTile(int, int)} 
invocation.
-     * The returned bounds are zero-based (may not be the bounds in image 
coordinates).
      *
      * <h4>Design note</h4>
-     * We use AWT rectangle instead of JavaFX rectangle
+     * We use <abbr>AWT</abbr> rectangle instead of JavaFX rectangle
      * because generally we use AWT for everything related to {@link 
RenderedImage}.
      *
      * @param  tileX  <var>x</var> coordinates of the tile for which to get 
the bounds.
@@ -560,51 +552,39 @@ public class GridView extends Control {
      * @return the zero-based bounds of the specified tile in the image.
      */
     final Rectangle getTileBounds(final int tileX, final int tileY) {
-        return new Rectangle(tileX * tileWidth  + tileGridXOffset,
-                             tileY * tileHeight + tileGridYOffset,
+        return new Rectangle(Numerics.clamp(tileGridXOffset + 
Math.multiplyFull(tileX, tileWidth)),
+                             Numerics.clamp(tileGridYOffset + 
Math.multiplyFull(tileY, tileHeight)),
                              tileWidth, tileHeight);
     }
 
     /**
-     * Converts a grid row index to tile index. Note that those {@link 
RenderedImage}
-     * tile coordinates do not necessarily start at 0; negative values may be 
valid.
+     * Formats the sample value at the image coordinates. If the tile is not 
available at the time
+     * that this method is invoked, then the tile will be loaded in a 
background thread and the grid
+     * view will be refreshed when the tile become available.
      *
-     * @see GridRow#tileY
-     */
-    final int toTileY(final int row) {
-        return Math.floorDiv(Math.subtractExact(row, tileGridYOffset), 
tileHeight);
-    }
-
-    /**
-     * Returns the sample value in the given column of the given row. If the 
tile is not available at the
-     * time this method is invoked, then the tile will loaded in a background 
thread and the grid view will
-     * be refreshed when the tile become available.
+     * @param  x  image <var>x</var> coordinate of the sample value to get.
+     * @param  y  image <var>y</var> coordinate of the sample value to get.
+     * @return the sample value at the specified coordinates, or {@code null} 
if not available.
      *
-     * <p>The {@code tileY} parameter is computed by {@link #toTileY(int)} and 
stored in {@link GridRow}.</p>
+     * @see #formatCoordinateValue(long)
      *
-     * @param  tileY    arbitrary-based <var>y</var> coordinate of the tile.
-     * @param  row      zero-based <var>y</var> coordinate of sample to get 
(may differ from image coordinate Y).
-     * @param  column   zero-based <var>x</var> coordinate of sample to get 
(may differ from image coordinate X).
-     * @return the sample value in the specified column, or {@code null} if 
unknown (because the loading process
-     *         is still under progress), or the empty string ({@code ""}) if 
out of bounds.
-     * @throws ArithmeticException if an index is too large for the 32 bits 
integer capacity.
-     *
-     * @see GridRow#getSampleValue(int)
+     * @since 1.5
      */
-    final String getSampleValue(final int tileY, final int row, final int 
column) {
-        if (row < 0 || row >= height || column < 0 || column >= width) {
-            return OUT_OF_BOUNDS;
+    public final String formatSampleValue(final long x, final long y) {
+        if (x < minX || x >= maxX || y < minY || y >= maxY) {
+            return null;
         }
         /*
          * Fetch the tile where is located the (x,y) image coordinate of the 
pixel to get.
          * If that tile has never been requested before, or has been discarded 
by the cache,
-         * start a background thread for fetching the tile and return null 
immediately; this
+         * start a background thread for fetching the tile and return null 
immediately. This
          * method will be invoked again with the same coordinates after the 
tile become ready.
          */
-        final int tileX = Math.floorDiv(Math.subtractExact(column, 
tileGridXOffset), tileWidth);
+        final int tileX = Math.toIntExact(Math.floorDiv(x - tileGridXOffset, 
tileWidth));
+        final int tileY = Math.toIntExact(Math.floorDiv(y - tileGridYOffset, 
tileHeight));
         GridTile cache = lastTile;
         if (cache == null || cache.tileX != tileX || cache.tileY != tileY) {
-            final GridTile key = new GridTile(tileX, tileY, numXTiles);
+            final var key = new GridTile(tileX, tileY);
             cache = tiles.putIfAbsent(key, key);
             if (cache == null) cache = key;
             lastTile = cache;
@@ -614,28 +594,21 @@ public class GridView extends Control {
             cache.load(this);
             return null;
         }
-        /*
-         * At this point we have the tile. Get the desired number and format 
its string representation.
-         * As a slight optimization, we reuse the previous string 
representation if the number is the same.
-         * It may happen in particular with fill values.
-         */
-        return cellFormat.format(tile,
-                Math.addExact(column, minX),
-                Math.addExact(row,    minY),
-                getBand());
+        // The casts are sure to be valid because of the range check at the 
beginning of this method.
+        return cellFormat.format(tile, (int) x, (int) y, getBand());
     }
 
     /**
-     * Formats a row index or column index.
+     * Formats a <var>x</var> or <var>y</var> pixel coordinate values.
+     * They are the values to write in the header row or header column.
+     *
+     * @param  index  the pixel coordinate to format.
+     * @return string representation of the given pixel coordinate.
      *
-     * @param  index     the zero-based row or column index to format.
-     * @param  vertical  {@code true} if formatting row index, or {@code 
false} if formatting column index.
+     * @since 1.5
      */
-    final String formatHeaderValue(final int index, final boolean vertical) {
-        if (index >= 0 && index < (vertical ? height : width)) {
-            return cellFormat.format(headerFormat, index + (long) (vertical ? 
minY : minX));
-        }
-        return OUT_OF_BOUNDS;
+    public final String formatCoordinateValue(final long index) {
+        return cellFormat.format(headerFormat, index);
     }
 
     /**
@@ -651,18 +624,6 @@ public class GridView extends Control {
         return cellFormat.hasPattern() ? Optional.of(cellFormat) : 
Optional.empty();
     }
 
-    /**
-     * Converts and formats the given cell coordinates. An offset is added to 
the coordinate values for converting
-     * cell indices to pixel indices. Those two kind of indices often have the 
same values, but may differ if the
-     * {@link RenderedImage} uses a coordinate system where coordinates of the 
upper-left corner is not (0,0).
-     * Then the pixel coordinates are converted to "real world" coordinates 
and formatted.
-     */
-    final void formatCoordinates(final int x, final int y) {
-        if (controls != null) {
-            controls.status.setLocalCoordinates(minX + x, minY + y);
-        }
-    }
-
     /**
      * Hides coordinates in the status bar.
      */
@@ -676,7 +637,7 @@ public class GridView extends Control {
      * Creates a new instance of the skin responsible for rendering this grid 
view.
      * From the perspective of this {@link Control}, the {@link Skin} is a 
black box.
      * It listens and responds to changes in state of this grid view. This 
method is
-     * called if no skin is provided via CSS or {@link #setSkin(Skin)}.
+     * called if no skin is provided via <abbr>CSS</abbr> or {@link 
#setSkin(Skin)}.
      *
      * @return the renderer of this grid view.
      */
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java
 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java
index 9ba58d8854..02ae89b1a1 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/coverage/GridViewSkin.java
@@ -16,120 +16,116 @@
  */
 package org.apache.sis.gui.coverage;
 
+import java.util.List;
+import java.util.Arrays;
 import java.awt.image.RenderedImage;
-import javafx.beans.value.ChangeListener;
-import javafx.beans.value.ObservableValue;
-import javafx.collections.ObservableList;
-import javafx.geometry.HPos;
-import javafx.geometry.VPos;
+import javafx.geometry.Orientation;
 import javafx.scene.Node;
 import javafx.scene.Cursor;
 import javafx.scene.control.ScrollBar;
-import javafx.scene.control.skin.VirtualFlow;
-import javafx.scene.control.skin.VirtualContainerBase;
-import javafx.scene.layout.HBox;
+import javafx.scene.control.SkinBase;
 import javafx.scene.paint.Color;
 import javafx.scene.shape.Rectangle;
-import javafx.scene.text.FontWeight;
+import javafx.scene.text.Text;
 import javafx.scene.text.Font;
+import javafx.scene.text.FontWeight;
 import javafx.scene.input.MouseButton;
 import javafx.scene.input.MouseEvent;
 import javafx.scene.input.KeyEvent;
 import javafx.event.EventHandler;
 import javafx.event.EventType;
+import javafx.geometry.Bounds;
 import org.apache.sis.gui.internal.MouseDrags;
 import org.apache.sis.gui.internal.Styles;
+import org.apache.sis.util.privy.Numerics;
 
 
 /**
- * The {@link GridView} renderer as a virtualized and scrollable content.
- * The primary direction of virtualization is vertical (rows will stack 
vertically on top of each other).
- *
- * <p>Relationships:</p>
- * <ul>
- *   <li>This is created by {@link GridView#createDefaultSkin()}.</li>
- *   <li>The {@link GridView} which own this skin is given by {@link 
#getSkinnable()}.</li>
- *   <li>This {@code GridViewSkin} contains an arbitrary number of {@link 
GridRow} children.
- *       It should be limited to the number of children that are visible at 
the same time,
- *       not the total number of rows in the image.</li>
- * </ul>
+ * The {@link GridView} renderer as a scrollable content.
+ * This is created by {@link GridView#createDefaultSkin()}.
+ * The {@link GridView} which owns this skin is given by {@link 
#getSkinnable()}.
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
-final class GridViewSkin extends VirtualContainerBase<GridView, GridRow> 
implements EventHandler<MouseEvent> {
+final class GridViewSkin extends SkinBase<GridView> implements 
EventHandler<MouseEvent> {
     /**
-     * The cells that we put in the header row on the top of the view. The 
children list is initially empty;
-     * new elements are added or removed when first needed and when the view 
size changed.
+     * Margin to add to the header row or header column.
      */
-    private final HBox headerRow;
+    private static final int HEADER_MARGIN = 9;
 
     /**
-     * Background of the header row (top side) and header column (left side) 
of the view.
+     * Minimum cell width and height. Must be greater than zero, otherwise 
infinite loops may happen.
+     *
+     * @see #toValidCellSize(double)
      */
-    private final Rectangle topBackground, leftBackground;
+    private static final int MIN_CELL_SIZE = 4;
 
     /**
-     * Zero-based index of the first column visible in the view, ignoring the 
header column.
-     * This is equal to the {@link RenderedImage} <var>x</var> index if the 
image coordinates
-     * also start at zero (i.e. {@link RenderedImage#getMinX()} = 0).
+     * Minimal width and height of the error box for showing it.
+     */
+    private static final int MIN_ERROR_BOX_SIZE = 140;
+
+    /**
+     * The cells that we put in the header row on the top of the view.
+     * The length of this array is the number of columns that are visible.
+     * This array is recreated when the view's width changed.
      *
-     * <p>This field is written by {@link #layoutChildren(double, double, 
double, double)}.
-     * All other accesses (especially from outside of this class) should be 
read-only.</p>
+     * @see #topBackground
      */
-    int firstVisibleColumn;
+    private Text[] headerRow;
 
     /**
-     * Horizontal position in the virtual flow where to start writing the text 
of the header column.
-     * This value changes during horizontal scrolls, even if the cells 
continue to start at the same
-     * visual position on the screen. The position of the column showing 
{@link #firstVisibleColumn}
-     * sample values is {@code leftPosition} + {@link #headerWidth}, and that 
position is incremented
-     * by {@link #cellWidth} for all other columns.
+     * The cells that we put in the header column on the left of the view.
+     * The length of this array is the number of rows that are visible.
+     * This array is recreated when the view's height changed.
      *
-     * <p>This field is written by {@link #layoutChildren(double, double, 
double, double)}.
-     * All other accesses (especially from outside of this class) should be 
read-only.</p>
+     * @see #leftBackground
      */
-    double leftPosition;
+    private Text[] headerColumn;
 
     /**
-     * Horizontal position where to stop rendering the cells.
-     * This is {@link #leftPosition} + the view width.
+     * All grid cells (row major) containing the pixel values of the selected 
band.
+     * The length of this array shall be {@code headerRow.length * 
headerColumn.length}.
+     * This array is recreated when the view's size changed.
      *
-     * <p>This field is written by {@link #layoutChildren(double, double, 
double, double)}.
-     * All other accesses (especially from outside of this class) should be 
read-only.</p>
+     * @see #valuesRegionX()
+     * @see #valuesRegionY()
      */
-    double rightPosition;
+    private Text[] valueCells;
 
     /**
-     * Width of the header column ({@code headerWidth}) and of all other 
columns ({@code cellWidth}).
-     * Must be greater than zero, otherwise infinite loop may happen.
+     * Background of the header row (top side) of the view. The bottom 
coordinate of this
+     * rectangle is the <var>y</var> coordinate where to start drawing the 
value cells.
      *
-     * <p>This field is written by {@link #layoutChildren(double, double, 
double, double)}.
-     * All other accesses (especially from outside of this class) should be 
read-only.</p>
+     * @see #headerRow
+     * @see #valuesRegionY()
      */
-    double headerWidth, cellWidth;
+    private final Rectangle topBackground;
 
     /**
-     * Width of the region where to write the text in a cell. Should be equal 
or slightly smaller
-     * than {@link #cellWidth}. We use a smaller width for leaving a small 
margin between cells.
+     * Background of the header header column (left side) of the view. The 
right coordinate of
+     * this rectangle is the <var>x</var> coordinate where to start drawing 
the value cells.
      *
-     * <p>This field is written by {@link #layoutChildren(double, double, 
double, double)}.
-     * All other accesses (especially from outside of this class) should be 
read-only.</p>
+     * @see #headerColumn
+     * @see #valuesRegionX()
      */
-    double cellInnerWidth;
+    private final Rectangle leftBackground;
 
     /**
-     * Whether a new image has been set, in which case we should recompute 
everything
-     * including the labels in header row.
+     * Width of all columns other than the header column.
+     * This is the {@link GridView#cellWidth} value forced to at least {@value 
#MIN_CELL_SIZE}.
      */
-    private boolean layoutAll;
+    private double cellWidth;
 
     /**
-     * Whether the grid view contains at least one tile that we failed to 
fetch.
+     * Height of all cells other than the header row.
+     * This is the {@link GridView#cellHeight} value forced to at least 
{@value #MIN_CELL_SIZE}.
      */
-    private boolean hasErrors;
+    private double cellHeight;
 
     /**
-     * A rectangle around selected the cell in the content area or in the 
row/column header.
+     * A rectangle behind the selected cell in the content area or in the 
row/column header.
+     * Used to highlight which cells is below the mouse cursor.
      */
     private final Rectangle selection, selectedRow, selectedColumn;
 
@@ -142,101 +138,147 @@ final class GridViewSkin extends 
VirtualContainerBase<GridView, GridRow> impleme
 
     /**
      * Cursor position at the time of previous pan event.
+     * The coordinate units are related to {@link RenderedImage} coordinates.
      * This is used for computing the translation to apply during drag events.
      *
      * @see #onDrag(MouseEvent)
      */
     private double xPanPrevious, yPanPrevious;
 
+    /**
+     * Horizontal and vertical scroll bars. Units are {@link RenderedImage} 
pixel coordinates.
+     * The minimum value is the minimum image pixel coordinate, which is not 
necessarily zero.
+     */
+    private final ScrollBar xScrollBar, yScrollBar;
+
+    /**
+     * Range if indexes of the children which is an instance of {@link 
GridError}.
+     */
+    private int indexOfFirstError, indexAfterLastError;
+
+    /**
+     * The clip to apply on the view.
+     */
+    private final Rectangle clip;
+
     /**
      * Creates a new skin for the specified view.
      */
     GridViewSkin(final GridView view) {
         super(view);
-        headerRow = new HBox();
+        headerRow = new Text[0];
+        headerColumn = headerRow;
+        valueCells   = headerRow;
         /*
          * Main content where sample values will be shown.
          */
-        final VirtualFlow<GridRow> flow = getVirtualFlow();
-        flow.setCellFactory(GridRow::new);
-        flow.setFocusTraversable(true);
-        flow.setFixedCellSize(GridView.getSizeValue(view.cellHeight));
-        view.cellHeight .addListener((p,o,n) -> cellHeightChanged(n));
-        view.cellWidth  .addListener((p,o,n) -> cellWidthChanged(n, true));
-        view.headerWidth.addListener((p,o,n) -> cellWidthChanged(n, false));
+        cellWidth  = toValidCellSize(view.cellWidth.get());
+        cellHeight = toValidCellSize(view.cellHeight.get());
         /*
          * Rectangles for filling the background of the cells in the header 
row and header column.
-         * Those rectangles will be resized and relocated by the `layout(…)` 
method.
+         * Those rectangles will be resized and relocated by the 
`layoutChildren(…)` method.
          */
-        topBackground  = new Rectangle();
-        leftBackground = new Rectangle();
-        leftBackground.fillProperty().bind(view.headerBackground);
-        topBackground .fillProperty().bind(view.headerBackground);
+        final double valuesRegionX = toValidCellSize(view.headerWidth.get());
+        final double valuesRegionY = cellHeight + HEADER_MARGIN;     // No 
independent property yet.
+        leftBackground = new Rectangle(valuesRegionX, 0);
+        topBackground  = new Rectangle(0, valuesRegionY);
         /*
          * Rectangle around the selected cell (for example the cell below 
mouse position).
-         * Become visible only when the mouse enter in the widget area. The 
rectangles are
-         * declared unmanaged for avoiding relayout of the whole widget every 
time that a
-         * rectangle position changed.
+         * They become visible only when the mouse enter in the widget area.
          */
-        selection      = new Rectangle();
-        selectedRow    = new Rectangle();
-        selectedColumn = new Rectangle();
+        selection      = new Rectangle(valuesRegionX, valuesRegionY, 
cellWidth, cellHeight);
+        selectedRow    = new Rectangle(0, valuesRegionY, valuesRegionX, 
cellHeight);
+        selectedColumn = new Rectangle(valuesRegionX, 0, cellWidth, 
valuesRegionY);
         selection     .setFill(Styles.SELECTION_BACKGROUND);
         selectedRow   .setFill(Color.SILVER);
         selectedColumn.setFill(Color.SILVER);
         selection     .setVisible(false);
         selectedRow   .setVisible(false);
         selectedColumn.setVisible(false);
-        selection     .setManaged(false);
-        selectedRow   .setManaged(false);
-        selectedColumn.setManaged(false);
-        flow.setOnMouseExited((e) -> hideSelection());
-        /*
-         * The list of children is initially empty. We need to add the virtual 
flow
-         * (together with headers, selection, etc.), otherwise nothing will 
appear.
-         */
-        getChildren().addAll(topBackground, leftBackground, selectedColumn,
-                             selectedRow, headerRow, selection, flow);
         /*
-         * Keyboard and drag events for moving the viewed bounds.
+         * Scroll bars.
          */
+        xScrollBar = new ScrollBar();
+        yScrollBar = new ScrollBar();
+        yScrollBar.setOrientation(Orientation.VERTICAL);
+        view.setClip(clip = new Rectangle());
+    }
+
+    /**
+     * Registers the listeners. This is done outside the constructor because 
this
+     * method creates references from {@link GridView} to this {@code 
GridViewSkin}.
+     */
+    @Override
+    public void install() {
+        super.install();
+        xScrollBar.valueProperty().addListener((p,o,n) -> positionChanged(o, 
n, false));
+        yScrollBar.valueProperty().addListener((p,o,n) -> positionChanged(o, 
n, true));
+
+        final GridView view = getSkinnable();
+        topBackground .fillProperty().bind(view.headerBackground);
+        leftBackground.fillProperty().bind(view.headerBackground);
+        view.headerWidth.addListener((p,o,n) -> cellSizeChanged(o, n, 
HEADER_PROPERTY));
+        view.cellWidth  .addListener((p,o,n) -> cellSizeChanged(o, n, 
WIDTH_PROPERTY));
+        view.cellHeight .addListener((p,o,n) -> cellSizeChanged(o, n, 
HEIGHT_PROPERTY));
         view.addEventHandler(KeyEvent.KEY_PRESSED, this::onKeyTyped);
+        view.setOnMouseExited((e) -> hideSelection());
+        view.setOnMouseMoved(this);
         MouseDrags.setHandlers(view, this::onDrag);
     }
 
+    /**
+     * Removes the listeners installed by {@link #install()}.
+     *
+     * <h4>Limitations</h4>
+     * We don't have an easy way to remove the listeners on properties.
+     * But it should not be an issue, because this method is defined mostly as
+     * a matter of principle since we don't expect users to remove this skin.
+     */
+    @Override
+    public void dispose() {
+        final GridView view = getSkinnable();
+        topBackground .fillProperty().unbind();
+        leftBackground.fillProperty().unbind();
+        view.setOnMouseExited(null);
+        view.setOnMouseMoved(null);
+        MouseDrags.setHandlers(view, null);
+        super.dispose();
+    }
+
     /**
      * Invoked when the mouse is moving over the cells. This method computes 
cell indices
      * and draws the selection rectangle around that cell. Then, listeners are 
notified.
      *
-     * <p>This listener is registered for each {@link GridRow} instances.
-     * It is not designed for other kinds of event source.</p>
-     *
      * @see #onDrag(MouseEvent)
      */
     @Override
     public final void handle(final MouseEvent event) {
-        double x = event.getX() - (leftPosition + headerWidth);
-        boolean visible = (x >= 0);
+        final double valuesRegionX = valuesRegionX();
+        double x = (event.getX() - valuesRegionX) / cellWidth;
+        boolean visible = (x >= 0 && x < headerRow.length);
         if (visible) {
-            final double column = Math.floor(x / cellWidth);
-            visible = (column >= 0);
+            final double valuesRegionY = valuesRegionY();
+            double y = (event.getY() - valuesRegionY) / cellHeight;
+            visible = (y >= 0 && y < headerColumn.length);
             if (visible) {
-                final GridRow row = (GridRow) event.getSource();
-                double y = row.getLayoutY();
-                visible = y < ((Flow) getVirtualFlow()).getVisibleHeight();
-                if (visible) {
-                    x  = column * cellWidth + leftBackground.getWidth() + 
row.getLayoutX();
-                    y += topBackground.getHeight();
-                    selection.setX(x);                  // NOT equivalent to 
`relocate(x,y)`.
-                    selection.setY(y);
-                    selectedRow.setY(y);
-                    selectedColumn.setX(x);
-                    getSkinnable().formatCoordinates(firstVisibleColumn + 
(int) column, row.getIndex());
+                final double xminOfValues = xScrollBar.getValue();
+                final double yminOfValues = yScrollBar.getValue();
+                x = Math.floor(x + xminOfValues);
+                y = Math.floor(y + yminOfValues);
+                final double xpos = valuesRegionX + (x - xminOfValues) * 
cellWidth;
+                final double ypos = valuesRegionY + (y - yminOfValues) * 
cellHeight;
+                selection.setX(xpos);      // NOT equivalent to 
`relocate(x,y)`.
+                selection.setY(ypos);
+                selectedRow.setY(ypos);
+                selectedColumn.setX(xpos);
+                final GridControls controls = getSkinnable().controls;
+                if (controls != null) {
+                    controls.status.setLocalCoordinates(x, y);
                 }
             }
         }
-        selection     .setVisible(visible);
-        selectedRow   .setVisible(visible);
+        selection.setVisible(visible);
+        selectedRow.setVisible(visible);
         selectedColumn.setVisible(visible);
         if (!visible) {
             getSkinnable().hideCoordinates();
@@ -245,36 +287,35 @@ final class GridViewSkin extends 
VirtualContainerBase<GridView, GridRow> impleme
 
     /**
      * Invoked when the user presses the button, drags the grid and releases 
the button.
-     * The position of the selection rectangles become invalid has a result of 
the drag.
-     * Instead of bothering to adjust it, we hide it. It may also be less 
confusing for
-     * the user, because it shows that we are not exploring values of 
different cells.
      *
      * @see #handle(MouseEvent)
      */
     private void onDrag(final MouseEvent event) {
         if (event.getButton() == MouseButton.PRIMARY) {
-            final double x = event.getX() - leftBackground.getWidth();
-            final double y = event.getY() - topBackground.getHeight();
-            final Flow flow = (Flow) getVirtualFlow();
-            if (x >= 0 && y >= 0 && y < flow.getVisibleHeight()) {
-                final GridView view = getSkinnable();
-                final EventType<? extends MouseEvent> type = 
event.getEventType();
-                if (type == MouseEvent.MOUSE_PRESSED) {
-                    view.setCursor(Cursor.CLOSED_HAND);
-                    view.requestFocus();
+            final double x = (event.getX() - valuesRegionX()) / cellWidth;
+            if (x >= 0 && x < headerRow.length) {
+                final double y = (event.getY() - valuesRegionY()) / cellHeight;
+                if (y >= 0 && y < headerColumn.length) {
+                    final GridView view = getSkinnable();
+                    final EventType<? extends MouseEvent> type = 
event.getEventType();
+                    if (type == MouseEvent.MOUSE_PRESSED) {
+                        view.setCursor(Cursor.CLOSED_HAND);
+                        view.requestFocus();
+                        isDragging = true;
+                    } else if (isDragging) {
+                        if (type == MouseEvent.MOUSE_RELEASED) {
+                            view.setCursor(Cursor.DEFAULT);
+                            isDragging = false;
+                        }
+                        shift(xScrollBar, xPanPrevious - x, false);
+                        shift(yScrollBar, yPanPrevious - y, false);
+                    } else {
+                        return;
+                    }
                     xPanPrevious = x;
                     yPanPrevious = y;
-                    isDragging = true;
-                    hideSelection();
-                } else if (isDragging) {
-                    if (type == MouseEvent.MOUSE_RELEASED) {
-                        view.setCursor(Cursor.DEFAULT);
-                        isDragging = false;
-                    }
-                    xPanPrevious -= flow.scrollHorizontal(xPanPrevious - x);
-                    yPanPrevious -= flow.scrollPixels(yPanPrevious - y);
+                    event.consume();
                 }
-                event.consume();
             }
         }
     }
@@ -285,409 +326,406 @@ final class GridViewSkin extends 
VirtualContainerBase<GridView, GridRow> impleme
      * scrolling instead of the selection rectangle moving.
      */
     private void onKeyTyped(final KeyEvent event) {
-        double tx=0, ty=0;
+        int tx=0, ty=0;
         switch (event.getCode()) {
             case RIGHT: case KP_RIGHT: tx =  1; break;
             case LEFT:  case KP_LEFT:  tx = -1; break;
             case DOWN:  case KP_DOWN:  ty = +1; break;
             case UP:    case KP_UP:    ty = -1; break;
+            case PAGE_DOWN: ty = Math.max(headerColumn.length - 3,  1); break;
+            case PAGE_UP:   ty = Math.min(3 - headerColumn.length, -1); break;
             default: return;
         }
-        if (event.isShiftDown()) {
-            tx *= 10;
-            ty *= 10;
-        }
-        final Flow flow = (Flow) getVirtualFlow();
-        flow.scrollPixels(flow.getFixedCellSize() * ty);
-        flow.scrollHorizontal(cellWidth * tx);
-        hideSelection();
+        if (tx != 0) shift(xScrollBar, tx, true);
+        if (ty != 0) shift(yScrollBar, ty, true);
         event.consume();
     }
 
     /**
-     * Hides the selection when the mouse moved outside the grid view area,
-     * or when a drag or scrolling action is performed.
+     * Increments or decrements the value of the given scroll bar by the given 
amount.
+     *
+     * @param  bar    the scroll bar for which to modify the position.
+     * @param  shift  the increment to add, in units of {@link RenderedImage} 
coordinates.
      */
-    private void hideSelection() {
-        selection     .setVisible(false);
-        selectedRow   .setVisible(false);
-        selectedColumn.setVisible(false);
-        getSkinnable().hideCoordinates();
+    private static void shift(final ScrollBar bar, final double shift, final 
boolean snap) {
+        double value = bar.getValue() + shift;
+        if (snap) {
+            value = Math.rint(value);
+        }
+        bar.setValue(Math.max(bar.getMin(), Math.min(bar.getMax(), value)));
     }
 
     /**
-     * Invoked when the value of {@link GridView#cellHeight} property changed.
-     * This method copies the new value into {@link 
VirtualFlow#fixedCellSizeProperty()} after bounds check.
+     * Invoked when the scroll bar changed its position. This is not only in 
reaction to direct interaction
+     * with the scroll bar, but may also be in response to a key pressed on 
the keyboard or a drag event.
      */
-    private void cellHeightChanged(Number newValue) {
-        final Flow flow = (Flow) getVirtualFlow();
-        double value = newValue.doubleValue();
-        if (!(value >= GridView.MIN_CELL_SIZE)) {           // Use ! for 
catching NaN values.
-            value = GridView.MIN_CELL_SIZE;
+    private void positionChanged(final Number oldValue, final Number newValue, 
final boolean vertical) {
+        final double shift = newValue.doubleValue() - oldValue.doubleValue();
+        if (vertical) {
+            final double ypos = selection.getY() - shift * cellHeight;
+            selection.setY(ypos);
+            selectedRow.setY(ypos);
+        } else {
+            final double xpos = selection.getX() - shift * cellWidth;
+            selection.setX(xpos);
+            selectedColumn.setX(xpos);
         }
-        flow.setFixedCellSize(value);
-        selection.setVisible(false);
-        selection.setHeight(value);
-        contentChanged(false);
+        updateCellValues();
+    }
+
+    /**
+     * Identification of which size property has changed.
+     * Used as argument in {@link #cellSizeChanged(Number, int)}.
+     */
+    private static final int WIDTH_PROPERTY = 0, HEIGHT_PROPERTY = 1, 
HEADER_PROPERTY = 2;
+
+    /**
+     * Returns the given value inside the range expected by this class for 
cell values.
+     * We use this method instead of {@link Math#max(double, double)} because 
we want
+     * {@link Double#NaN} values to be replaced by {@value #MIN_CELL_SIZE}.
+     */
+    private static double toValidCellSize(final double value) {
+        return (value >= MIN_CELL_SIZE) ? value : MIN_CELL_SIZE;
     }
 
     /**
-     * Invoked when the cell width or header width changed.
-     * This method notifies all children about the new width.
+     * Invoked when a cell width or cell height changed.
      *
-     * @param  cell  {@code true} if modifying the width of cells, or
-     *               {@code false} if modifying the width of headers.
+     * @param  oldValue  the old cell width or cell height.
+     * @param  newValue  the new cell width or cell height.
+     * @param  property  one of the {@code *_PROPERTY} constants.
      */
-    private void cellWidthChanged(Number newValue, final boolean cell) {
-        final GridView view = getSkinnable();
-        final double width = view.getContentWidth();
-        for (final Node child : getChildren()) {
-            if (child instanceof GridRow) {             // The first instances 
are not a GridRow.
-                ((GridRow) child).setPrefWidth(width);
+    private void cellSizeChanged(final Number oldValue, final Number newValue, 
final int property) {
+        double value = toValidCellSize(newValue.doubleValue());
+        switch (property) {
+            case HEADER_PROPERTY: {
+                leftBackground.setWidth(value);
+                selectedRow.setWidth(value);
+                selectedColumn.setX(value);
+                break;
+            }
+            case WIDTH_PROPERTY: {
+                cellWidth = value;
+                selection.setWidth(value);
+                selectedColumn.setWidth(value);
+                if (value > oldValue.doubleValue()) {
+                    // Maybe there is enough space for the full pattern now.
+                    getSkinnable().cellFormat.restorePattern();
+                }
+                break;
+            }
+            case HEIGHT_PROPERTY: {
+                cellHeight = value;
+                selection.setHeight(value);
+                selectedRow.setHeight(value);
+                value += HEADER_MARGIN;     // No independent property yet.
+                selectedColumn.setHeight(value);
+                topBackground.setWidth(value);
+                break;
             }
         }
-        if (cell) {
-            selection.setVisible(false);
-            selection.setWidth(newValue.doubleValue());
-        }
-        layoutAll = true;
-        contentChanged(false);
+        hideSelection();
+        getSkinnable().requestLayout();
+    }
+
+    /**
+     * Hides the selection when the mouse moved outside the grid view area,
+     * or when a drag or scrolling action is performed.
+     */
+    private void hideSelection() {
+        selection     .setVisible(false);
+        selectedRow   .setVisible(false);
+        selectedColumn.setVisible(false);
+        getSkinnable().hideCoordinates();
     }
 
     /**
-     * Invoked when an error occurred while fetching a tile. The given {@link 
GridError} node is added as last
-     * child (i.e. will be drawn on top of everything else). That child will 
be removed if a new image is set.
+     * Invoked when an error occurred while fetching a tile. The given {@link 
GridError}
+     * node will be added after the value cells, in order to be drawn on top 
of them.
+     * That child will be removed if a new image is set.
      */
     final void errorOccurred(final GridError error) {
-        hasErrors = true;
-        getChildren().add(error);
+        getChildren().add(indexAfterLastError, error);
+        indexAfterLastError++;  // Increment only after success.
     }
 
     /**
-     * Removes the given error. This method is invoked when the user wants to 
try again to fetch a tile.
-     * Callers is responsible for invoking {@link GridTile#clear()}.
+     * Returns all {@link GridError} instances. This is a view over the 
children of this skin.
      */
-    final void removeError(final GridError error) {
-        final ObservableList<Node> children = getChildren();
-        children.remove(error);
-        // The list should never be empty, so IndexOutOfBoundsException here 
would be a bug.
-        hasErrors = children.get(children.size() - 1) instanceof GridError;
-        contentChanged(false);
+    private List<Node> errors() {
+        return getChildren().subList(indexOfFirstError, indexAfterLastError);
     }
 
     /**
-     * Invoked when the content may have changed. If {@code all} is {@code 
true}, then everything
-     * may have changed including the number of rows and columns. If {@code 
all} is {@code false}
-     * then the number of rows and columns is assumed the same.
-     *
-     * <p>This method is invoked by {@link GridView} when the image has 
changed ({@code all=true}),
-     * or the band in the image to show has changed ({@code all=false}).</p>
-     *
-     * @see GridView#contentChanged(boolean)
-     */
-    final void contentChanged(final boolean all) {
-        if (all) {
-            updateItemCount();
-            getChildren().removeIf((node) -> (node instanceof GridError));
-            layoutAll = true;
-            hasErrors = false;
+     * Removes the given error. This method is invoked when the user wants to 
try again to fetch a tile.
+     * Callers is responsible for invoking {@link GridTile#clear()}.
+     */
+    final void removeError(final GridError error) {
+        if (errors().remove(error)) {
+            indexAfterLastError--;
         }
-        /*
-         * Following call may be redundant with `updateItemCount()` except if 
the number of
-         * rows did not changed, in which case `updateItemCount()` may have 
sent no event.
-         */
-        ((Flow) getVirtualFlow()).changed(null, null, null);
     }
 
     /**
-     * Creates the virtual flow used by this {@link GridViewSkin}. The virtual 
flow
-     * created by this method registers a listener for horizontal scroll bar 
events.
+     * Removes all {@link GridError} instances.
      */
-    @Override
-    protected VirtualFlow<GridRow> createVirtualFlow() {
-        return new Flow(getSkinnable());
+    final void clear() {
+        errors().clear();
     }
 
     /**
-     * The virtual flow used by {@link GridViewSkin}. We define that class
-     * mostly for getting access to the protected {@link #getHbar()} method.
-     * There are two main properties that we want:
+     * Resizes the given array of cells. If the array become longer, new 
labels are created.
+     * This is an helper method for {@link #layoutChildren(double, double, 
double, double)}.
      *
-     * <ul>
-     *   <li>{@link #getHorizontalPosition()} for the position of the 
horizontal scroll bar.</li>
-     *   <li>{@link #getWidth()} for the width of the visible region.
-     * </ul>
-     *
-     * Those two properties are used for creating the minimal amount
-     * of {@link GridCell}s needed for rendering the {@link GridRow}.
+     * @param  cells   the array to resize.
+     * @param  count   the desired number of elements.
+     * @param  header  whether the cells are for a header row or column.
+     * @return the resized array.
      */
-    private static final class Flow extends VirtualFlow<GridRow> implements 
ChangeListener<Number> {
-        /**
-         * Creates a new flow for the given view. This method registers 
listeners
-         * on the properties that may require a redrawn of the full view port.
-         */
-        Flow(final GridView view) {
-            setPannable(false);         // We will use our own pan listeners.
-            getHbar().valueProperty().addListener(this);
-            view.bandProperty.addListener(this);
-            view.cellSpacing .addListener(this);
-            // Other listeners are registered by enclosing class.
-        }
-
-        /**
-         * The position of the horizontal scroll bar. This is a value between 
0 and
-         * the width that the {@link GridView} would have if we were showing 
it fully.
-         */
-        final double getHorizontalPosition() {
-            return getHbar().getValue();
-        }
-
-        /**
-         * Attempts to scroll horizontally the view by the given number of 
pixels.
-         *
-         * @param  delta  the number of pixels to scroll.
-         * @return the number of pixels actually moved.
-         */
-        final double scrollHorizontal(final double delta) {
-            final ScrollBar bar = getHbar();
-            final double previous = bar.getValue();
-            final double value = Math.max(bar.getMin(), Math.min(bar.getMax(), 
previous + delta));
-            bar.setValue(value);
-            return value - previous;
-        }
-
-        /**
-         * Returns the height of the view area, not counting the horizontal 
scroll bar.
-         * This height does not include the row header neither, because it is 
managed by
-         * a separated node ({@link #headerRow}).
-         */
-        final double getVisibleHeight() {
-            double height = getHeight();
-            final ScrollBar bar = getHbar();
-            if (bar.isVisible()) {
-                height -= bar.getHeight();
+    private static Text[] resize(Text[] cells, final int count, final boolean 
header) {
+        int i = cells.length;
+        if (i != count) {
+            cells = Arrays.copyOf(cells, count);
+            if (count > i) {
+                final Font font = header ? Font.font(null, FontWeight.BOLD, 
-1) : null;
+                do {
+                    final var cell = new Text();
+                    if (header) {
+                        cell.setFont(font);
+                    }
+                    cells[i] = cell;
+                } while (++i < count);
             }
-            return height;
-        }
-
-        /**
-         * Invoked when the content to show changed because of a change in a 
property.
-         * The most important event is a change in the position of horizontal 
scroll bar,
-         * which is handled as a change of content because we will need to 
change values
-         * shown by the cells (because we reuse a small number of cells in 
visible region).
-         * But this method is also invoked for real changes of content like 
changes in the
-         * index of the band to show, provided that the number of rows and 
columns is the same.
-         *
-         * @param  property  the property that changed (ignored).
-         * @param  oldValue  the old value (ignored).
-         * @param  newValue  the new value (ignored).
-         */
-        @Override
-        public void changed(ObservableValue<? extends Number> property, Number 
oldValue, Number newValue) {
-            // Inform VirtualFlow that a layout pass should be done, but no 
GridRows have been added or removed.
-            reconfigureCells();
         }
+        return cells;
     }
 
     /**
-     * Invoked when it is possible that the item count has changed. JavaFX may 
invoke this method
-     * when scrolling has occurred, the control has resized, <i>etc.</i>, but 
for {@link GridView}
-     * the count will change only if a new {@link RenderedImage} has been 
specified.
+     * Returns the leftmost coordinate where value cells are rendered. This is 
in units of the
+     * coordinates given to the {@link #layoutChildren(double, double, double, 
double)} method,
+     * with {@code xmin} assumed to be zero.
      */
-    @Override
-    protected void updateItemCount() {
-        /*
-         * VirtualFlow.setCellCount(int) indicates the number of cells that 
should be in the flow.
-         * When the cell count changes, VirtualFlow responds by updating the 
visuals. If the items
-         * backing the cells change but the count has not changed, then 
reconfigureCells() should
-         * be invoked instead. This is done by the `Flow` inner class above.
-         */
-        getVirtualFlow().setCellCount(getItemCount());      // Fires event 
only if count changed.
+    private double valuesRegionX() {
+        return leftBackground.getWidth();
     }
 
     /**
-     * Returns the total number of image rows, including those that are 
currently hidden because
-     * they are out of view. The returned value is (indirectly) {@link 
RenderedImage#getHeight()}.
+     * Returns the topmost coordinate where value cells are rendered. This is 
in units of the
+     * coordinates given to the {@link #layoutChildren(double, double, double, 
double)} method,
+     * with {@code ymin} assumed to be zero.
      */
-    @Override
-    public int getItemCount() {
-        return getSkinnable().getImageHeight();
+    private double valuesRegionY() {
+        return topBackground.getHeight();
     }
 
     /**
      * Called during the layout pass of the scene graph. The (x,y) coordinates 
are usually zero
      * and the (width, height) are the size of the control as shown (not the 
full content size).
-     * Current implementation sets the virtual flow size to the given size.
+     * Current implementation assume that the visible part is the given size.
+     *
+     * <h4>Assumptions</h4>
+     * This implementation ignores {@code xmin} and {@code ymin} on the 
assumption that they are always zero.
+     * If this assumption is false, we need to add those values in pretty much 
everything that set a position
+     * in this class.
      */
     @Override
-    protected void layoutChildren(final double x, final double y, final double 
width, final double height) {
+    @SuppressWarnings("LocalVariableHidesMemberVariable")
+    protected void layoutChildren(final double xmin, final double ymin, final 
double width, final double height) {
+        // Do not invoke `super.layoutChildren(…)` because we manage all 
children outselves.
+        clip.setX(xmin);
+        clip.setY(xmin);
+        clip.setWidth(width);
+        clip.setHeight(height);
+
+        final double sy = height - Styles.SCROLLBAR_HEIGHT;
+        xScrollBar.resizeRelocate(0, sy, width, Styles.SCROLLBAR_HEIGHT);
+        yScrollBar.resizeRelocate(width - Styles.SCROLLBAR_WIDTH, 0, 
Styles.SCROLLBAR_WIDTH, sy);
+        leftBackground.setHeight(height);
+        topBackground .setWidth (width);
+
+        final double valuesRegionX = valuesRegionX();
+        final double valuesRegionY = valuesRegionY();
+        final int nx = Math.max(0, (int) Math.ceil((width  - valuesRegionX - 
Styles.SCROLLBAR_WIDTH)  / cellWidth  + 1));
+        final int ny = Math.max(0, (int) Math.ceil((height - valuesRegionY - 
Styles.SCROLLBAR_HEIGHT) / cellHeight + 1));
+        final int n  = Math.multiplyExact(nx, ny);
+
+        // Intentionally use `GridError` instead of `Node` for detecting 
errors.
+        final GridError[] errors = errors().toArray(GridError[]::new);
+        final Text[] headerRow    = resize(this.headerRow,    nx, true);
+        final Text[] headerColumn = resize(this.headerColumn, ny, true);
+        final Text[] valueCells   = resize(this.valueCells,   n, false);
+        final Node[] all = new Node[headerRow.length + headerColumn.length + 
valueCells.length + errors.length + 7];
+
+        // Order matter: nodes added last will hide nodes added first.
+        int i = 0;
+        all[i++] = selection;
+        System.arraycopy(valueCells, 0, all, i, n);
+        final int indexOfFirstError = i += n;
+        System.arraycopy(errors, 0, all, i, errors.length);
+        final int indexAfterLastError = i += errors.length;
+        all[i++] = topBackground;
+        all[i++] = selectedColumn;
+        all[i++] = leftBackground;
+        all[i++] = selectedRow;
+        System.arraycopy(headerRow,    0, all, i, nx); i += nx;
+        System.arraycopy(headerColumn, 0, all, i, ny); i += ny;
+        all[i++] = xScrollBar;
+        all[i++] = yScrollBar;
+        if (i != all.length) {
+            throw new AssertionError(i);
+        }
         /*
-         * Super-class only invokes `updateItemCount()` if needed.
-         * It does not perform any layout by itself in this method.
+         * It is important to ensure that all nodes are unmanaged, otherwise 
adding the nodes
+         * causes a new layout attempt, which causes infinite enqueued calls 
of this method
+         * in the JavaFX events thread (the application is not frozen, but 
wastes CPU).
          */
-        super.layoutChildren(x, y, width, height);
+        for (Node node : all) {
+            node.setManaged(false);
+        }
+        getChildren().setAll(all);
+        this.headerRow           = headerRow;       // Save only after the 
above succeeded.
+        this.headerColumn        = headerColumn;
+        this.valueCells          = valueCells;
+        this.indexOfFirstError   = indexOfFirstError;
+        this.indexAfterLastError = indexAfterLastError;
         final GridView view = getSkinnable();
-        double cellSpacing  = Math.min(view.cellSpacing.get(), cellWidth);
-        if (!(cellSpacing  >= 0)) cellSpacing = 0;          // Use ! for 
catching NaN (cannot use Math.max).
-        /*
-         * Do layout of the flow first because it may cause scroll bars to 
appear or disappear,
-         * which may change the size calculations done after that. The flow is 
located below the
-         * header row, so we adjust y and height accordingly.
-         */
-        final Flow   flow         = (Flow) getVirtualFlow();
-        final double cellHeight   = flow.getFixedCellSize();
-        final double headerHeight = cellHeight + 2*cellSpacing;
-        final double dataY        = y + headerHeight;
-        final double dataHeight   = height - headerHeight;
-        layoutAll |= (flow.getWidth() != width) || (flow.getHeight() != 
dataHeight);
-        flow.resizeRelocate(x, dataY, width, dataHeight);
-        /*
-         * Recompute all values which will be needed by GridRowSkin. They are 
mostly information about
-         * the horizontal dimension, because the vertical dimension is already 
managed by VirtualFlow.
-         * We compute here for avoiding to recompute the same values in each 
GridRowSkin instance.
-         */
-        final double oldPos = leftPosition;
-        headerWidth         = GridView.getSizeValue(view.headerWidth);
-        cellWidth           = GridView.getSizeValue(view.cellWidth);
-        cellInnerWidth      = cellWidth - cellSpacing;
-        leftPosition        = flow.getHorizontalPosition();         // 
Horizontal position in the virtual view.
-        rightPosition       = leftPosition + width;                 // 
Horizontal position where to stop.
-        firstVisibleColumn  = (int) (leftPosition / cellWidth);     // 
Zero-based column index in the image.
+        view.scaleScrollBar(xScrollBar, nx, false);
+        view.scaleScrollBar(yScrollBar, ny, true);
+        updateCellValues();
+    }
+
+    /**
+     * Formats the values in all cells. This method is invoked when a new 
image is set or after scrolling.
+     * It can also be invoked when the band to show has changed.
+     */
+    final void updateCellValues() {
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        final double cellWidth  = this.cellWidth,
+                     cellHeight = this.cellHeight;
+
+        final double valuesRegionX = valuesRegionX();
+        final double valuesRegionY = valuesRegionY();
+        double xminOfValues = xScrollBar.getValue();
+        double yminOfValues = yScrollBar.getValue();
+        double xposOfValues = valuesRegionX - (xminOfValues - (xminOfValues = 
Math.floor(xminOfValues))) * cellWidth;
+        double yposOfValues = valuesRegionY - (yminOfValues - (yminOfValues = 
Math.floor(yminOfValues))) * cellHeight;
+        final long xmin = (long) xminOfValues;  // Those `double` values are 
already integers at this point.
+        final long ymin = (long) yminOfValues;
         /*
-         * Set the rectangle position before to do final adjustment on cell 
position,
-         * because the background to fill should include the `cellSpacing` 
margin.
+         * Render the header column. That column has its own width,
+         * different than the width of all other cells.
          */
-        topBackground .setX(x);                                     // As a 
matter of principle, but should be zero.
-        topBackground .setY(y);
-        topBackground .setWidth(width);
-        topBackground .setHeight(headerHeight);
-        leftBackground.setX(x);
-        leftBackground.setY(dataY);
-        leftBackground.setWidth(headerWidth);
-        leftBackground.setHeight(flow.getVisibleHeight());
-        selection     .setWidth (cellWidth);
-        selectedRow   .setWidth (headerWidth);
-        selectedColumn.setWidth (cellWidth);
-        selection     .setHeight(cellHeight);
-        selectedRow   .setHeight(cellHeight);
-        selectedColumn.setHeight(headerHeight);
-        selectedRow   .setX(x);
-        selectedColumn.setY(y);
-        if (cellSpacing < headerWidth) {
-            headerWidth  -= cellSpacing;
-            leftPosition += cellSpacing;
+        double cellInnerWidth = toValidCellSize(valuesRegionX - HEADER_MARGIN);
+        final GridView view = getSkinnable();
+        Text[] cells = headerColumn;
+        final int ny = cells.length;
+        for (int y=0; y<ny; y++) {
+            final double ypos = yposOfValues + cellHeight*y;
+            setRightAlignedText(null, cells[y], 
view.formatCoordinateValue(ymin + y), 0, ypos, cellInnerWidth, cellHeight);
         }
         /*
-         * Reformat the row header if its content changed. It may be because a 
horizontal scroll has been
-         * detected (in which case values changed), or because the view size 
changed (in which case cells
-         * may need to be added or removed).
+         * Compute the width of all cells other than the header column,
+         * then render the header row. Finally, render the cell values.
          */
-        if (layoutAll || oldPos != leftPosition) {
-            layoutInArea(headerRow, x, y, width, headerHeight,
-                         Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.LEFT, 
VPos.TOP);
-            final ObservableList<Node> children = headerRow.getChildren();
-            final int count   = children.size();
-            final int missing = (int) Math.ceil((width - headerWidth) / 
cellWidth) - count;
-            if (missing != 0) {
-                if (missing < 0) {
-                    children.remove(missing + count, count);        // Too 
many children. Remove the extra ones.
-                } else {
-                    final GridCell[] more = new GridCell[missing];
-                    final Font font = Font.font(null, FontWeight.BOLD, -1);
-                    for (int i=0; i<missing; i++) {
-                        final GridCell cell = new GridCell();
-                        cell.setFont(font);
-                        more[i] = cell;
-                    }
-                    children.addAll(more);             // Single addAll(…) 
operation for sending only one event.
+        cellInnerWidth = cellWidth;
+        double cellSpacing = view.cellSpacing.get();
+        if (cellSpacing > 0) {
+            cellInnerWidth = toValidCellSize(cellInnerWidth - cellSpacing);
+        }
+        cells = headerRow;
+        final int nx = cells.length;
+        for (int x=0; x<nx; x++) {
+            final double xpos = xposOfValues + cellWidth*x;
+            setRightAlignedText(null, cells[x], 
view.formatCoordinateValue(xmin + x), xpos, 0, cellInnerWidth, valuesRegionY);
+        }
+        cells = valueCells;
+redo:   for (;;) {
+            for (int i=0; i<cells.length; i++) {
+                final int x = i % nx;
+                final int y = i / nx;
+                final double xpos = xposOfValues + x * cellWidth;
+                final double ypos = yposOfValues + y * cellHeight;
+                if (setRightAlignedText(view, cells[i], 
view.formatSampleValue(xmin+x, ymin+y),
+                                        xpos, ypos, cellInnerWidth, 
cellHeight))
+                {
+                    // The format pattern has been made shorter. Rewrite 
previous cells with the new pattern.
+                    continue redo;
                 }
             }
-            double pos = x + headerWidth;
-            int column = firstVisibleColumn;
-            for (final Node cell : children) {
-                ((GridCell) cell).setText(view.formatHeaderValue(column++, 
false));
-                layoutInArea(cell, pos, y, cellWidth, headerHeight, 
Node.BASELINE_OFFSET_SAME_AS_HEIGHT, HPos.RIGHT, VPos.CENTER);
-                pos += cellWidth;
-            }
-            /*
-             * For a mysterious reason, all row headers except the first one 
(0) are invisible on the first time
-             * that the grid is shown. I have been unable to identify the 
reason; all `GridCell` are created and
-             * received a non-empty text string. Doing a full layout again 
makes them appear. So as a workaround
-             * we request the next layout to be full again if it seems that we 
have done the initial layout. The
-             * very first layout create one cell (count = 0 & missing = 1), 
the next layout create missing cells
-             * (count = 1 & missing = 18) — this is where we want to force a 
third layout — then the third layout
-             * is stable (count = 19 & missing = 0).
-             */
-            layoutAll = (count <= missing);
+            break;
         }
         /*
-         * Update position of the highlights at mouse cursor position. Usually 
the correction computed below is
-         * zero and this block does not change any position (but it may change 
the geographic coordinates shown
-         * in status bar). However if the user was scrolling and reached the 
end of the virtial flow, the last
-         * scrolling action may have caused a displacement which is a 
fractional number of cells, in which case
-         * the highlights appear misaligned if we do not apply the correction 
below.
+         * Relocate the error boxes. Usually, there is none.
          */
-        if (selection.isVisible()) {
-            GridRow row = flow.getFirstVisibleCell();
-            if (row != null) {
-                double sy = selection.getY() - (row.getLayoutY() + 
headerHeight);
-                final int i = ((int) Math.rint(sy / cellHeight)) + 
row.getIndex();
-                row = flow.getCell(i);                      // Empty cell if 
beyond the range.
-                sy  = row.getLayoutY() + headerHeight;      // Usually same as 
`selection.y` (see above comment).
-                final double offset = row.getLayoutX() + 
leftBackground.getWidth();
-                final double column = Math.rint((selection.getX() - offset) / 
cellWidth);
-                final double sx     = column * cellWidth + offset;
-                selection.setX(sx);
-                selection.setY(sy);
-                selectedRow.setY(sy);
-                selectedColumn.setX(sx);
-                getSkinnable().formatCoordinates(firstVisibleColumn + (int) 
column, i);
+        if (indexOfFirstError != indexAfterLastError) {
+            final var viewArea = new java.awt.Rectangle(Numerics.clamp(xmin), 
Numerics.clamp(ymin), nx-1, ny-1);
+            for (Node node : errors()) {
+                boolean visible = false;
+                final var error = (GridError) node;
+                final var area = error.getVisibleRegion(viewArea);
+                if (!area.isEmpty()) {
+                    error.resizeRelocate(
+                            (area.x - xmin) * cellWidth  + xposOfValues,
+                            (area.y - ymin) * cellHeight + yposOfValues,
+                             area.width     * cellWidth,
+                             area.height    * cellHeight);
+                    /*
+                     * If after layout the error message size appears too 
small, hide it.
+                     */
+                    visible = error.getHeight() >= MIN_ERROR_BOX_SIZE
+                           && error.getWidth()  >= MIN_ERROR_BOX_SIZE;
+                }
+                error.setVisible(visible);
             }
         }
-        if (hasErrors) {
-            computeErrorBounds(flow);
-        }
     }
 
     /**
-     * If an error exists somewhere, computes as estimation of the visible 
region
-     * as zero-based column and row indices. We use an AWT rectangle instead of
-     * JavaFX object because this rectangle will be intersected with AWT 
rectangle.
+     * Sets the text and sets its position for making it aligned on the right.
+     * If the text does not fit in the space specified by {@code width}, then
+     * this method tries to format the number using a shorter pattern.
+     * If this method cannot use a shorter pattern, then the text is truncated.
+     *
+     * <p>This method returns {@code true} if it has shortened the pattern.
+     * In such case, the caller should rewrite all cells in order to use a
+     * consistent pattern.</p>
+     *
+     * @param view    the view for which cells are rendered, or {@code null} 
for not shortening the pattern.
+     * @param cell    the cell where to set the text.
+     * @param value   the text to set.
+     * @param x       horizontal position of the cell.
+     * @param y       vertical position of the cell.
+     * @param width   width of the cell.
+     * @param height  height of the cell.
      */
-    private void computeErrorBounds(final Flow flow) {
-        final java.awt.Rectangle viewArea = new java.awt.Rectangle();
-        final GridRow first = flow.getFirstVisibleCell();
-        int firstVisibleRow = 0;
-        if (first != null) {
-            viewArea.x      = firstVisibleColumn;
-            viewArea.y      = firstVisibleRow = first.getIndex();
-            viewArea.width  = (int) ((flow.getWidth() - 
leftBackground.getWidth()) / cellWidth);
-            viewArea.height = (int) (flow.getVisibleHeight() / 
flow.getFixedCellSize());
-        }
-        final ObservableList<Node> children = getChildren();
-        for (int i=children.size(); --i >= 0;) {
-            final Node node = children.get(i);
-            if (!(node instanceof GridError)) break;
-            final GridError error = (GridError) node;
-            boolean visible = false;
-            final java.awt.Rectangle area = error.getVisibleRegion(viewArea);
-            if (!area.isEmpty()) {
-                final double cellHeight = flow.getFixedCellSize();
-                final double width  = area.width  * cellWidth;
-                final double height = area.height * cellHeight;
-                layoutInArea(error,
-                        cellWidth  * (area.x - firstVisibleColumn) + 
leftBackground.getWidth(),
-                        cellHeight * (area.y - firstVisibleRow)    + 
topBackground.getHeight(),
-                        width, height, Node.BASELINE_OFFSET_SAME_AS_HEIGHT, 
HPos.CENTER, VPos.CENTER);
-                /*
-                 * If after layout the error message size appears too large 
for the remaining space, hide it.
-                 * The intent is to avoid having the message and buttons on 
top of cell values in valid tiles.
-                 * It does not seem to work fully however (some overlaps can 
still happen), so we added some
-                 * inset to mitigate the problem and also for more aerated 
presentation.
-                 */
-                visible = error.getHeight() <= height && error.getWidth() <= 
width;
+    private static boolean setRightAlignedText(GridView view, final Text cell, 
String value,
+            double x, double y, final double width, final double height)
+    {
+        boolean redo = false;
+        cell.setText(value);
+        if (value != null) {
+            double dx, dy;
+            for (;;) {
+                Bounds bounds = cell.getLayoutBounds();
+                dy = height - bounds.getHeight();
+                dx = width  - bounds.getWidth();
+                if (dx >= 0) break;
+                if (view != null) {
+                    if (view.cellFormat.shorterPattern()) {
+                        redo = true;
+                        break;
+                    }
+                    view = null;
+                }
+                int cut = value.length() - 2;
+                if (cut < 0) break;
+                value = value.substring(0, cut) + '…';
+                cell.setText(value);
             }
-            error.setVisible(visible);
+            x += dx;
+            y += dy * 0.5;
         }
+        cell.relocate(x, y);
+        return redo;
     }
 }
diff --git 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java
index 52d2ef15db..b2255ec381 100644
--- 
a/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java
+++ 
b/optional/src/org.apache.sis.gui/main/org/apache/sis/gui/internal/Styles.java
@@ -44,10 +44,15 @@ import static org.apache.sis.gui.internal.LogHandler.LOGGER;
  */
 public final class Styles {
     /**
-     * Approximate size of vertical scroll bar.
+     * Approximate width of the vertical scroll bar.
      */
     public static final int SCROLLBAR_WIDTH = 20;
 
+    /**
+     * Approximate height of the horizontal scroll bar.
+     */
+    public static final int SCROLLBAR_HEIGHT = 20;
+
     /**
      * "Standard" height of table rows. Can be approximate.
      */
@@ -164,9 +169,9 @@ public final class Styles {
      * @return a pane with each (label, control) pair on a row.
      */
     public static GridPane createControlGrid(int row, final Label... controls) 
{
-        final GridPane gp = new GridPane();
-        final ColumnConstraints labelColumn   = new ColumnConstraints();
-        final ColumnConstraints controlColumn = new ColumnConstraints();
+        final var gp = new GridPane();
+        final var labelColumn   = new ColumnConstraints();
+        final var controlColumn = new ColumnConstraints();
         labelColumn  .setHgrow(Priority.NEVER);
         controlColumn.setHgrow(Priority.ALWAYS);
         gp.getColumnConstraints().setAll(labelColumn, controlColumn);
@@ -192,8 +197,8 @@ public final class Styles {
      * @param  gp  the grid pane in which to set row constraints.
      */
     public static void setAllRowToSameHeight(final GridPane gp) {
-        final RowConstraints[] constraints = new 
RowConstraints[gp.getRowCount()];
-        final RowConstraints c = new RowConstraints();
+        final var constraints = new RowConstraints[gp.getRowCount()];
+        final var c = new RowConstraints();
         c.setPercentHeight(100.0 / constraints.length);
         Arrays.fill(constraints, c);
         gp.getRowConstraints().setAll(constraints);
diff --git 
a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java
 
b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java
index f7495c58ea..9a043cb10d 100644
--- 
a/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java
+++ 
b/optional/src/org.apache.sis.gui/test/org/apache/sis/gui/coverage/GridViewApp.java
@@ -65,8 +65,8 @@ public final class GridViewApp extends Application {
      */
     @Override
     public void start(final Stage window) {
-        final GridView   view = new GridView();
-        final BorderPane pane = new BorderPane(view);
+        final var view = new GridView();
+        final var pane = new BorderPane(view);
         window.setTitle("GridView Test");
         window.setScene(new Scene(pane));
         window.setWidth (400);
@@ -91,7 +91,7 @@ public final class GridViewApp extends Application {
      * have artificial errors in order to see the error controls.
      */
     private static TiledImageMock createImage() {
-        final TiledImageMock image = new TiledImageMock(
+        final var image = new TiledImageMock(
                 DataBuffer.TYPE_USHORT, 1,
                 -50,                            // minX
                  70,                            // minY

Reply via email to