1 /* 2 * Copyright (c) 2011, 2016, Oracle and/or its affiliates. All rights reserved. 3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. 4 * 5 * This code is free software; you can redistribute it and/or modify it 6 * under the terms of the GNU General Public License version 2 only, as 7 * published by the Free Software Foundation. Oracle designates this 8 * particular file as subject to the "Classpath" exception as provided 9 * by Oracle in the LICENSE file that accompanied this code. 10 * 11 * This code is distributed in the hope that it will be useful, but WITHOUT 12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 14 * version 2 for more details (a copy is included in the LICENSE file that 15 * accompanied this code). 16 * 17 * You should have received a copy of the GNU General Public License version 18 * 2 along with this work; if not, write to the Free Software Foundation, 19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 20 * 21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA 22 * or visit www.oracle.com if you need additional information or have any 23 * questions. 24 */ 25 26 package javafx.scene.control.skin; 27 28 import com.sun.javafx.scene.control.skin.Utils; 29 import javafx.beans.property.ObjectProperty; 30 import javafx.collections.WeakListChangeListener; 31 import java.util.ArrayList; 32 import java.util.List; 33 import java.util.Map; 34 import java.util.WeakHashMap; 35 import java.util.concurrent.atomic.AtomicBoolean; 36 37 import javafx.collections.FXCollections; 38 import javafx.collections.ListChangeListener; 39 import javafx.collections.ObservableList; 40 import javafx.event.EventHandler; 41 import javafx.geometry.NodeOrientation; 42 import javafx.scene.Cursor; 43 import javafx.scene.Node; 44 import javafx.scene.control.*; 45 import javafx.scene.input.MouseEvent; 46 import javafx.scene.paint.Color; 47 import javafx.scene.shape.Rectangle; 48 import javafx.util.Callback; 49 50 /** 51 * <p>This class is used to construct the header of a TableView. We take the approach 52 * that every TableView header is nested - even if it isn't. This allows for us 53 * to use the same code for building a single row of TableColumns as we would 54 * with a heavily nested sequences of TableColumns. Because of this, the 55 * TableHeaderRow class consists of just one instance of a NestedTableColumnHeader. 56 * 57 * @since 9 58 * @see TableColumnHeader 59 * @see TableHeaderRow 60 * @see TableColumnBase 61 */ 62 public class NestedTableColumnHeader extends TableColumnHeader { 63 64 /*************************************************************************** 65 * * 66 * Static Fields * 67 * * 68 **************************************************************************/ 69 70 static final String DEFAULT_STYLE_CLASS = "nested-column-header"; 71 72 private static final int DRAG_RECT_WIDTH = 4; 73 74 private static final String TABLE_COLUMN_KEY = "TableColumn"; 75 private static final String TABLE_COLUMN_HEADER_KEY = "TableColumnHeader"; 76 77 78 79 /*************************************************************************** 80 * * 81 * Private Fields * 82 * * 83 **************************************************************************/ 84 85 /** 86 * Represents the actual columns directly contained in this nested column. 87 * It does NOT include ANY of the children of these columns, if any exist. 88 */ 89 private ObservableList<? extends TableColumnBase> columns; 90 91 private TableColumnHeader label; 92 93 private ObservableList<TableColumnHeader> columnHeaders; 94 private ObservableList<TableColumnHeader> unmodifiableColumnHeaders; 95 96 // used for column resizing 97 private double lastX = 0.0F; 98 private double dragAnchorX = 0.0; 99 100 // drag rectangle overlays 101 private Map<TableColumnBase<?,?>, Rectangle> dragRects = new WeakHashMap<>(); 102 103 boolean updateColumns = true; 104 105 106 107 /*************************************************************************** 108 * * 109 * Constructor * 110 * * 111 **************************************************************************/ 112 113 /** 114 * Creates a new NestedTableColumnHeader instance to visually represent the given 115 * {@link TableColumnBase} instance. 116 * 117 * @param tc The table column to be visually represented by this instance. 118 */ 119 public NestedTableColumnHeader(final TableColumnBase tc) { 120 super(tc); 121 122 setFocusTraversable(false); 123 124 // init UI 125 label = createTableColumnHeader(getTableColumn()); 126 label.setTableHeaderRow(getTableHeaderRow()); 127 label.setParentHeader(getParentHeader()); 128 label.setNestedColumnHeader(this); 129 130 if (getTableColumn() != null) { 131 changeListenerHandler.registerChangeListener(getTableColumn().textProperty(), e -> 132 label.setVisible(getTableColumn().getText() != null && ! getTableColumn().getText().isEmpty())); 133 } 134 } 135 136 137 138 /*************************************************************************** 139 * * 140 * Listeners * 141 * * 142 **************************************************************************/ 143 144 private final ListChangeListener<TableColumnBase> columnsListener = c -> { 145 setHeadersNeedUpdate(); 146 }; 147 148 private final WeakListChangeListener weakColumnsListener = 149 new WeakListChangeListener(columnsListener); 150 151 private static final EventHandler<MouseEvent> rectMousePressed = me -> { 152 Rectangle rect = (Rectangle) me.getSource(); 153 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 154 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 155 156 if (! header.isColumnResizingEnabled()) return; 157 158 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 159 // can be received by both nodes, leading to less than ideal UX, hence the check here. 160 if (header.getTableHeaderRow().columnDragLock) return; 161 162 if (me.isConsumed()) return; 163 me.consume(); 164 165 if (me.getClickCount() == 2 && me.isPrimaryButtonDown()) { 166 // the user wants to resize the column such that its 167 // width is equal to the widest element in the column 168 TableSkinUtils.resizeColumnToFitContent(header.getTableSkin(), column, -1); 169 } else { 170 // rather than refer to the rect variable, we just grab 171 // it from the source to prevent a small memory leak. 172 Rectangle innerRect = (Rectangle) me.getSource(); 173 double startX = header.getTableHeaderRow().sceneToLocal(innerRect.localToScene(innerRect.getBoundsInLocal())).getMinX() + 2; 174 header.dragAnchorX = me.getSceneX(); 175 header.columnResizingStarted(startX); 176 } 177 }; 178 179 private static final EventHandler<MouseEvent> rectMouseDragged = me -> { 180 Rectangle rect = (Rectangle) me.getSource(); 181 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 182 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 183 184 if (! header.isColumnResizingEnabled()) return; 185 186 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 187 // can be received by both nodes, leading to less than ideal UX, hence the check here. 188 if (header.getTableHeaderRow().columnDragLock) return; 189 190 if (me.isConsumed()) return; 191 me.consume(); 192 193 header.columnResizing(column, me); 194 }; 195 196 private static final EventHandler<MouseEvent> rectMouseReleased = me -> { 197 Rectangle rect = (Rectangle) me.getSource(); 198 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 199 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 200 201 if (! header.isColumnResizingEnabled()) return; 202 203 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 204 // can be received by both nodes, leading to less than ideal UX, hence the check here. 205 if (header.getTableHeaderRow().columnDragLock) return; 206 207 if (me.isConsumed()) return; 208 me.consume(); 209 210 header.columnResizingComplete(column, me); 211 }; 212 213 private static final EventHandler<MouseEvent> rectCursorChangeListener = me -> { 214 Rectangle rect = (Rectangle) me.getSource(); 215 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 216 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 217 218 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 219 // can be received by both nodes, leading to less than ideal UX, hence the check here. 220 if (header.getTableHeaderRow().columnDragLock) return; 221 222 if (header.getCursor() == null) { // If there's a cursor for the whole header, don't override it 223 rect.setCursor(header.isColumnResizingEnabled() && rect.isHover() && 224 column.isResizable() ? Cursor.H_RESIZE : null); 225 } 226 }; 227 228 229 230 /*************************************************************************** 231 * * 232 * Public Methods * 233 * * 234 **************************************************************************/ 235 236 /** {@inheritDoc} */ 237 @Override void dispose() { 238 super.dispose(); 239 240 if (label != null) { 241 label.dispose(); 242 } 243 244 if (getColumns() != null) { 245 getColumns().removeListener(weakColumnsListener); 246 } 247 248 for (int i = 0; i < getColumnHeaders().size(); i++) { 249 TableColumnHeader header = getColumnHeaders().get(i); 250 header.dispose(); 251 } 252 253 for (Rectangle rect : dragRects.values()) { 254 if (rect != null) { 255 rect.visibleProperty().unbind(); 256 } 257 } 258 dragRects.clear(); 259 getChildren().clear(); 260 261 changeListenerHandler.dispose(); 262 } 263 264 /** 265 * Returns an unmodifiable list of the {@link TableColumnHeader} instances 266 * that are children of this NestedTableColumnHeader. 267 */ 268 public final ObservableList<TableColumnHeader> getColumnHeaders() { 269 if (columnHeaders == null) { 270 columnHeaders = FXCollections.<TableColumnHeader>observableArrayList(); 271 unmodifiableColumnHeaders = FXCollections.unmodifiableObservableList(columnHeaders); 272 } 273 return unmodifiableColumnHeaders; 274 } 275 276 /** {@inheritDoc} */ 277 @Override protected void layoutChildren() { 278 double w = getWidth() - snappedLeftInset() - snappedRightInset(); 279 double h = getHeight() - snappedTopInset() - snappedBottomInset(); 280 281 int labelHeight = (int) label.prefHeight(-1); 282 283 if (label.isVisible()) { 284 // label gets to span whole width and sits at top 285 label.resize(w, labelHeight); 286 label.relocate(snappedLeftInset(), snappedTopInset()); 287 } 288 289 // children columns need to share the total available width 290 double x = snappedLeftInset(); 291 final double height = snapSizeY(h - labelHeight); 292 for (int i = 0, max = getColumnHeaders().size(); i < max; i++) { 293 TableColumnHeader n = getColumnHeaders().get(i); 294 if (! n.isVisible()) continue; 295 296 double prefWidth = n.prefWidth(height); 297 298 // position the column header in the default location... 299 n.resize(prefWidth, height); 300 n.relocate(x, labelHeight + snappedTopInset()); 301 302 // // ...but, if there are no children of this column, we should ensure 303 // // that it is resized vertically such that it goes to the very 304 // // bottom of the table header row. 305 // if (getTableHeaderRow() != null && n.getCol().getColumns().isEmpty()) { 306 // Bounds bounds = getTableHeaderRow().sceneToLocal(n.localToScene(n.getBoundsInLocal())); 307 // prefHeight = getTableHeaderRow().getHeight() - bounds.getMinY(); 308 // n.resize(prefWidth, prefHeight); 309 // } 310 311 // shuffle along the x-axis appropriately 312 x += prefWidth; 313 314 // position drag overlay to intercept column resize requests 315 Rectangle dragRect = dragRects.get(n.getTableColumn()); 316 if (dragRect != null) { 317 dragRect.setHeight(n.getDragRectHeight()); 318 dragRect.relocate(x - DRAG_RECT_WIDTH / 2, snappedTopInset() + labelHeight); 319 } 320 } 321 } 322 323 // sum up all children columns 324 /** {@inheritDoc} */ 325 @Override protected double computePrefWidth(double height) { 326 checkState(); 327 328 double width = 0.0F; 329 330 if (getColumns() != null) { 331 for (TableColumnHeader c : getColumnHeaders()) { 332 if (c.isVisible()) { 333 width += c.computePrefWidth(height); 334 } 335 } 336 } 337 338 return width; 339 } 340 341 /** {@inheritDoc} */ 342 @Override protected double computePrefHeight(double width) { 343 checkState(); 344 345 double height = 0.0F; 346 347 if (getColumnHeaders() != null) { 348 for (TableColumnHeader n : getColumnHeaders()) { 349 height = Math.max(height, n.prefHeight(-1)); 350 } 351 } 352 353 return height + label.prefHeight(-1) + snappedTopInset() + snappedBottomInset(); 354 } 355 356 /** 357 * Creates a new TableColumnHeader instance for the given TableColumnBase instance. The general pattern for 358 * implementing this method is as follows: 359 * 360 * <ul> 361 * <li>If the given TableColumnBase instance is null, has no child columns, or if the given TableColumnBase 362 * instance equals the TableColumnBase instance returned by calling {@link #getTableColumn()}, then it is 363 * suggested to return a {@link TableColumnHeader} instance comprised of the given column.</li> 364 * <li>Otherwise, we can presume that the given TableColumnBase instance has child columns, and in this case 365 * it is suggested to return a {@link NestedTableColumnHeader} instance instead.</li> 366 * </ul> 367 * 368 * <strong>Note: </strong>In most circumstances this method should not be overridden, but in some circumstances it 369 * makes sense (e.g. testing, or when extreme customization is desired). 370 * 371 * @return A new TableColumnHeader instance. 372 */ 373 protected TableColumnHeader createTableColumnHeader(TableColumnBase col) { 374 return col == null || col.getColumns().isEmpty() || col == getTableColumn() ? 375 new TableColumnHeader(col) : 376 new NestedTableColumnHeader(col); 377 } 378 379 380 381 /*************************************************************************** 382 * * 383 * Private Implementation * 384 * * 385 **************************************************************************/ 386 387 @Override void initStyleClasses() { 388 getStyleClass().setAll(DEFAULT_STYLE_CLASS); 389 installTableColumnStyleClassListener(); 390 } 391 392 @Override void setTableHeaderRow(TableHeaderRow header) { 393 super.setTableHeaderRow(header); 394 395 // it's only now that a skin might be available 396 if (getTableSkin() != null) { 397 changeListenerHandler.registerChangeListener(TableSkinUtils.columnResizePolicyProperty(getTableSkin()), e -> updateContent()); 398 } 399 400 label.setTableHeaderRow(header); 401 402 // tell all children columns what TableHeader they belong to 403 for (TableColumnHeader c : getColumnHeaders()) { 404 c.setTableHeaderRow(header); 405 } 406 } 407 408 @Override void setParentHeader(NestedTableColumnHeader parentHeader) { 409 super.setParentHeader(parentHeader); 410 label.setParentHeader(parentHeader); 411 } 412 413 ObservableList<? extends TableColumnBase> getColumns() { 414 return columns; 415 } 416 417 void setColumns(ObservableList<? extends TableColumnBase> newColumns) { 418 if (this.columns != null) { 419 this.columns.removeListener(weakColumnsListener); 420 } 421 422 this.columns = newColumns; 423 424 if (this.columns != null) { 425 this.columns.addListener(weakColumnsListener); 426 } 427 } 428 429 void updateTableColumnHeaders() { 430 // watching for changes to the view columns in either table or tableColumn. 431 if (getTableColumn() == null && getTableSkin() != null) { 432 setColumns(TableSkinUtils.getColumns(getTableSkin())); 433 } else if (getTableColumn() != null) { 434 setColumns(getTableColumn().getColumns()); 435 } 436 437 // update the column headers... 438 439 // iterate through all columns, unless we've got no child columns 440 // any longer, in which case we should switch to a TableColumnHeader 441 // instead 442 if (getColumns().isEmpty()) { 443 // iterate through all current headers, telling them to clean up 444 for (int i = 0; i < getColumnHeaders().size(); i++) { 445 TableColumnHeader header = getColumnHeaders().get(i); 446 header.dispose(); 447 } 448 449 // switch out to be a TableColumn instead, if we have a parent header 450 NestedTableColumnHeader parentHeader = getParentHeader(); 451 if (parentHeader != null) { 452 List<TableColumnHeader> parentColumnHeaders = parentHeader.getColumnHeaders(); 453 int index = parentColumnHeaders.indexOf(this); 454 if (index >= 0 && index < parentColumnHeaders.size()) { 455 parentColumnHeaders.set(index, createColumnHeader(getTableColumn())); 456 } 457 } else { 458 // otherwise just remove all the columns 459 columnHeaders.clear(); 460 } 461 } else { 462 List<TableColumnHeader> oldHeaders = new ArrayList<>(getColumnHeaders()); 463 List<TableColumnHeader> newHeaders = new ArrayList<>(); 464 465 for (int i = 0; i < getColumns().size(); i++) { 466 TableColumnBase<?,?> column = getColumns().get(i); 467 if (column == null || ! column.isVisible()) continue; 468 469 // check if the header already exists and reuse it 470 boolean found = false; 471 for (int j = 0; j < oldHeaders.size(); j++) { 472 TableColumnHeader oldColumn = oldHeaders.get(j); 473 if (oldColumn.represents(column)) { 474 newHeaders.add(oldColumn); 475 found = true; 476 break; 477 } 478 } 479 480 // otherwise create a new table column header 481 if (!found) { 482 newHeaders.add(createColumnHeader(column)); 483 } 484 } 485 486 columnHeaders.setAll(newHeaders); 487 488 // dispose all old headers 489 oldHeaders.removeAll(newHeaders); 490 for (int i = 0; i < oldHeaders.size(); i++) { 491 oldHeaders.get(i).dispose(); 492 } 493 } 494 495 // update the content 496 updateContent(); 497 498 // RT-33596: Do CSS now, as we are in the middle of layout pass and the headers are new Nodes w/o CSS done 499 for (TableColumnHeader header : getColumnHeaders()) { 500 header.applyCss(); 501 } 502 } 503 504 // Used to test whether this column header properly represents the given column. 505 // In particular, whether it has child column headers for all child columns 506 boolean represents(TableColumnBase<?, ?> column) { 507 if (column.getColumns().isEmpty()) { 508 // this column has no children, but we are in a NestedTableColumnHeader instance, 509 // so the match is bad. 510 return false; 511 } 512 513 if (column != getTableColumn()) { 514 return false; 515 } 516 517 final int columnCount = column.getColumns().size(); 518 final int headerCount = getColumnHeaders().size(); 519 if (columnCount != headerCount) { 520 return false; 521 } 522 523 for (int i = 0; i < columnCount; i++) { 524 // we expect the order of all children to match the order of the headers 525 TableColumnBase<?,?> childColumn = column.getColumns().get(i); 526 TableColumnHeader childHeader = getColumnHeaders().get(i); 527 if (!childHeader.represents(childColumn)) { 528 return false; 529 } 530 } 531 return true; 532 } 533 534 /** {@inheritDoc} */ 535 @Override double getDragRectHeight() { 536 return label.prefHeight(-1); 537 } 538 539 void setHeadersNeedUpdate() { 540 updateColumns = true; 541 542 // go through children columns - they should update too 543 for (int i = 0; i < getColumnHeaders().size(); i++) { 544 TableColumnHeader header = getColumnHeaders().get(i); 545 if (header instanceof NestedTableColumnHeader) { 546 ((NestedTableColumnHeader)header).setHeadersNeedUpdate(); 547 } 548 } 549 requestLayout(); 550 } 551 552 private void updateContent() { 553 // create a temporary list so we only do addAll into the main content 554 // observableArrayList once. 555 final List<Node> content = new ArrayList<Node>(); 556 557 // the label is the region that sits above the children columns 558 content.add(label); 559 560 // all children columns 561 content.addAll(getColumnHeaders()); 562 563 // Small transparent overlays that sit at the start and end of each 564 // column to intercept user drag gestures to enable column resizing. 565 if (isColumnResizingEnabled()) { 566 rebuildDragRects(); 567 content.addAll(dragRects.values()); 568 } 569 570 getChildren().setAll(content); 571 } 572 573 private void rebuildDragRects() { 574 if (! isColumnResizingEnabled()) return; 575 576 getChildren().removeAll(dragRects.values()); 577 578 for (Rectangle rect : dragRects.values()) { 579 rect.visibleProperty().unbind(); 580 } 581 dragRects.clear(); 582 583 List<? extends TableColumnBase> columns = getColumns(); 584 585 if (columns == null) { 586 return; 587 } 588 589 boolean isConstrainedResize = false; 590 TableViewSkinBase tableSkin = getTableSkin(); 591 Callback<ResizeFeaturesBase,Boolean> columnResizePolicy = TableSkinUtils.columnResizePolicyProperty(tableSkin).get(); 592 if (columnResizePolicy != null) { 593 isConstrainedResize = 594 tableSkin instanceof TableViewSkin ? TableView.CONSTRAINED_RESIZE_POLICY.equals(columnResizePolicy) : 595 tableSkin instanceof TreeTableViewSkin ? TreeTableView.CONSTRAINED_RESIZE_POLICY.equals(columnResizePolicy) : 596 false; 597 } 598 599 // RT-32547 - don't show resize cursor when in constrained resize mode 600 // and there is only one column 601 if (isConstrainedResize && TableSkinUtils.getVisibleLeafColumns(tableSkin).size() == 1) { 602 return; 603 } 604 605 for (int col = 0; col < columns.size(); col++) { 606 if (isConstrainedResize && col == getColumns().size() - 1) { 607 break; 608 } 609 610 final TableColumnBase c = columns.get(col); 611 final Rectangle rect = new Rectangle(); 612 rect.getProperties().put(TABLE_COLUMN_KEY, c); 613 rect.getProperties().put(TABLE_COLUMN_HEADER_KEY, this); 614 rect.setWidth(DRAG_RECT_WIDTH); 615 rect.setHeight(getHeight() - label.getHeight()); 616 rect.setFill(Color.TRANSPARENT); 617 rect.visibleProperty().bind(c.visibleProperty().and(c.resizableProperty())); 618 rect.setOnMousePressed(rectMousePressed); 619 rect.setOnMouseDragged(rectMouseDragged); 620 rect.setOnMouseReleased(rectMouseReleased); 621 rect.setOnMouseEntered(rectCursorChangeListener); 622 rect.setOnMouseExited(rectCursorChangeListener); 623 624 dragRects.put(c, rect); 625 } 626 } 627 628 private void checkState() { 629 if (updateColumns) { 630 updateTableColumnHeaders(); 631 updateColumns = false; 632 } 633 } 634 635 private TableColumnHeader createColumnHeader(TableColumnBase col) { 636 TableColumnHeader newCol = createTableColumnHeader(col); 637 newCol.setTableHeaderRow(getTableHeaderRow()); 638 newCol.setParentHeader(this); 639 return newCol; 640 } 641 642 643 644 /*************************************************************************** 645 * * 646 * Private Implementation: Column Resizing * 647 * * 648 **************************************************************************/ 649 650 private boolean isColumnResizingEnabled() { 651 // this used to check if ! PlatformUtil.isEmbedded(), but has been changed 652 // to always return true (for now), as we want to support column resizing 653 // everywhere 654 return true; 655 } 656 657 private void columnResizingStarted(double startX) { 658 setCursor(Cursor.H_RESIZE); 659 columnReorderLine.setLayoutX(startX); 660 } 661 662 private void columnResizing(TableColumnBase col, MouseEvent me) { 663 double draggedX = me.getSceneX() - dragAnchorX; 664 if (getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { 665 draggedX = -draggedX; 666 } 667 double delta = draggedX - lastX; 668 boolean allowed = TableSkinUtils.resizeColumn(getTableSkin(), col, delta); 669 if (allowed) { 670 lastX = draggedX; 671 } 672 } 673 674 private void columnResizingComplete(TableColumnBase col, MouseEvent me) { 675 setCursor(null); 676 columnReorderLine.setTranslateX(0.0F); 677 columnReorderLine.setLayoutX(0.0F); 678 lastX = 0.0F; 679 } 680 }