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 getStyleClass().setAll(DEFAULT_STYLE_CLASS); 123 setFocusTraversable(false); 124 125 // init UI 126 label = new TableColumnHeader(getTableColumn()); 127 label.setTableHeaderRow(getTableHeaderRow()); 128 label.setParentHeader(getParentHeader()); 129 label.setNestedColumnHeader(this); 130 131 if (getTableColumn() != null) { 132 changeListenerHandler.registerChangeListener(getTableColumn().textProperty(), e -> 133 label.setVisible(getTableColumn().getText() != null && ! getTableColumn().getText().isEmpty())); 134 } 135 } 136 137 138 139 /*************************************************************************** 140 * * 141 * Listeners * 142 * * 143 **************************************************************************/ 144 145 private final ListChangeListener<TableColumnBase> columnsListener = c -> { 146 setHeadersNeedUpdate(); 147 }; 148 149 private final WeakListChangeListener weakColumnsListener = 150 new WeakListChangeListener(columnsListener); 151 152 private static final EventHandler<MouseEvent> rectMousePressed = me -> { 153 Rectangle rect = (Rectangle) me.getSource(); 154 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 155 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 156 157 if (! header.isColumnResizingEnabled()) return; 158 159 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 160 // can be received by both nodes, leading to less than ideal UX, hence the check here. 161 if (header.getTableHeaderRow().columnDragLock) return; 162 163 if (me.isConsumed()) return; 164 me.consume(); 165 166 if (me.getClickCount() == 2 && me.isPrimaryButtonDown()) { 167 // the user wants to resize the column such that its 168 // width is equal to the widest element in the column 169 TableSkinUtils.resizeColumnToFitContent(header.getTableSkin(), column, -1); 170 } else { 171 // rather than refer to the rect variable, we just grab 172 // it from the source to prevent a small memory leak. 173 Rectangle innerRect = (Rectangle) me.getSource(); 174 double startX = header.getTableHeaderRow().sceneToLocal(innerRect.localToScene(innerRect.getBoundsInLocal())).getMinX() + 2; 175 header.dragAnchorX = me.getSceneX(); 176 header.columnResizingStarted(startX); 177 } 178 }; 179 180 private static final EventHandler<MouseEvent> rectMouseDragged = me -> { 181 Rectangle rect = (Rectangle) me.getSource(); 182 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 183 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 184 185 if (! header.isColumnResizingEnabled()) return; 186 187 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 188 // can be received by both nodes, leading to less than ideal UX, hence the check here. 189 if (header.getTableHeaderRow().columnDragLock) return; 190 191 if (me.isConsumed()) return; 192 me.consume(); 193 194 header.columnResizing(column, me); 195 }; 196 197 private static final EventHandler<MouseEvent> rectMouseReleased = me -> { 198 Rectangle rect = (Rectangle) me.getSource(); 199 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 200 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 201 202 if (! header.isColumnResizingEnabled()) return; 203 204 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 205 // can be received by both nodes, leading to less than ideal UX, hence the check here. 206 if (header.getTableHeaderRow().columnDragLock) return; 207 208 if (me.isConsumed()) return; 209 me.consume(); 210 211 header.columnResizingComplete(column, me); 212 }; 213 214 private static final EventHandler<MouseEvent> rectCursorChangeListener = me -> { 215 Rectangle rect = (Rectangle) me.getSource(); 216 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 217 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 218 219 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 220 // can be received by both nodes, leading to less than ideal UX, hence the check here. 221 if (header.getTableHeaderRow().columnDragLock) return; 222 223 if (header.getCursor() == null) { // If there's a cursor for the whole header, don't override it 224 rect.setCursor(header.isColumnResizingEnabled() && rect.isHover() && 225 column.isResizable() ? Cursor.H_RESIZE : null); 226 } 227 }; 228 229 230 231 /*************************************************************************** 232 * * 233 * Public Methods * 234 * * 235 **************************************************************************/ 236 237 /** {@inheritDoc} */ 238 @Override void dispose() { 239 super.dispose(); 240 241 if (label != null) { 242 label.dispose(); 243 } 244 245 if (getColumns() != null) { 246 getColumns().removeListener(weakColumnsListener); 247 } 248 249 for (int i = 0; i < getColumnHeaders().size(); i++) { 250 TableColumnHeader header = getColumnHeaders().get(i); 251 header.dispose(); 252 } 253 254 for (Rectangle rect : dragRects.values()) { 255 if (rect != null) { 256 rect.visibleProperty().unbind(); 257 } 258 } 259 dragRects.clear(); 260 getChildren().clear(); 261 262 changeListenerHandler.dispose(); 263 } 264 265 /** 266 * Returns an unmodifiable list of the {@link TableColumnHeader} instances 267 * that are children of this NestedTableColumnHeader. 268 */ 269 public final ObservableList<TableColumnHeader> getColumnHeaders() { 270 if (columnHeaders == null) { 271 columnHeaders = FXCollections.<TableColumnHeader>observableArrayList(); 272 unmodifiableColumnHeaders = FXCollections.unmodifiableObservableList(columnHeaders); 273 } 274 return unmodifiableColumnHeaders; 275 } 276 277 /** {@inheritDoc} */ 278 @Override protected void layoutChildren() { 279 double w = getWidth() - snappedLeftInset() - snappedRightInset(); 280 double h = getHeight() - snappedTopInset() - snappedBottomInset(); 281 282 int labelHeight = (int) label.prefHeight(-1); 283 284 if (label.isVisible()) { 285 // label gets to span whole width and sits at top 286 label.resize(w, labelHeight); 287 label.relocate(snappedLeftInset(), snappedTopInset()); 288 } 289 290 // children columns need to share the total available width 291 double x = snappedLeftInset(); 292 final double height = snapSizeY(h - labelHeight); 293 for (int i = 0, max = getColumnHeaders().size(); i < max; i++) { 294 TableColumnHeader n = getColumnHeaders().get(i); 295 if (! n.isVisible()) continue; 296 297 double prefWidth = n.prefWidth(height); 298 299 // position the column header in the default location... 300 n.resize(prefWidth, height); 301 n.relocate(x, labelHeight + snappedTopInset()); 302 303 // // ...but, if there are no children of this column, we should ensure 304 // // that it is resized vertically such that it goes to the very 305 // // bottom of the table header row. 306 // if (getTableHeaderRow() != null && n.getCol().getColumns().isEmpty()) { 307 // Bounds bounds = getTableHeaderRow().sceneToLocal(n.localToScene(n.getBoundsInLocal())); 308 // prefHeight = getTableHeaderRow().getHeight() - bounds.getMinY(); 309 // n.resize(prefWidth, prefHeight); 310 // } 311 312 // shuffle along the x-axis appropriately 313 x += prefWidth; 314 315 // position drag overlay to intercept column resize requests 316 Rectangle dragRect = dragRects.get(n.getTableColumn()); 317 if (dragRect != null) { 318 dragRect.setHeight(n.getDragRectHeight()); 319 dragRect.relocate(x - DRAG_RECT_WIDTH / 2, snappedTopInset() + labelHeight); 320 } 321 } 322 } 323 324 // sum up all children columns 325 /** {@inheritDoc} */ 326 @Override protected double computePrefWidth(double height) { 327 checkState(); 328 329 double width = 0.0F; 330 331 if (getColumns() != null) { 332 for (TableColumnHeader c : getColumnHeaders()) { 333 if (c.isVisible()) { 334 width += c.computePrefWidth(height); 335 } 336 } 337 } 338 339 return width; 340 } 341 342 /** {@inheritDoc} */ 343 @Override protected double computePrefHeight(double width) { 344 checkState(); 345 346 double height = 0.0F; 347 348 if (getColumnHeaders() != null) { 349 for (TableColumnHeader n : getColumnHeaders()) { 350 height = Math.max(height, n.prefHeight(-1)); 351 } 352 } 353 354 return height + label.prefHeight(-1) + snappedTopInset() + snappedBottomInset(); 355 } 356 357 /** 358 * Creates a new TableColumnHeader instance for the given TableColumnBase instance. By default this method should 359 * not be overridden, but in some circumstances it makes sense (e.g. testing, or when extreme customization is desired). 360 * If the given TableColumnBase instance has child columns, then it is suggested to return a 361 * {@link NestedTableColumnHeader} instance instead. 362 * 363 * @return A new TableColumnHeader instance. 364 */ 365 protected TableColumnHeader createTableColumnHeader(TableColumnBase col) { 366 return col.getColumns().isEmpty() ? 367 new TableColumnHeader(col) : 368 new NestedTableColumnHeader(col); 369 } 370 371 372 373 /*************************************************************************** 374 * * 375 * Private Implementation * 376 * * 377 **************************************************************************/ 378 379 @Override void setTableHeaderRow(TableHeaderRow header) { 380 super.setTableHeaderRow(header); 381 382 // it's only now that a skin might be available 383 if (getTableSkin() != null) { 384 changeListenerHandler.registerChangeListener(TableSkinUtils.columnResizePolicyProperty(getTableSkin()), e -> updateContent()); 385 } 386 387 label.setTableHeaderRow(header); 388 389 // tell all children columns what TableHeader they belong to 390 for (TableColumnHeader c : getColumnHeaders()) { 391 c.setTableHeaderRow(header); 392 } 393 } 394 395 @Override void setParentHeader(NestedTableColumnHeader parentHeader) { 396 super.setParentHeader(parentHeader); 397 label.setParentHeader(parentHeader); 398 } 399 400 ObservableList<? extends TableColumnBase> getColumns() { 401 return columns; 402 } 403 404 void setColumns(ObservableList<? extends TableColumnBase> newColumns) { 405 if (this.columns != null) { 406 this.columns.removeListener(weakColumnsListener); 407 } 408 409 this.columns = newColumns; 410 411 if (this.columns != null) { 412 this.columns.addListener(weakColumnsListener); 413 } 414 } 415 416 void updateTableColumnHeaders() { 417 // watching for changes to the view columns in either table or tableColumn. 418 if (getTableColumn() == null && getTableSkin() != null) { 419 setColumns(TableSkinUtils.getColumns(getTableSkin())); 420 } else if (getTableColumn() != null) { 421 setColumns(getTableColumn().getColumns()); 422 } 423 424 // update the column headers... 425 426 // iterate through all columns, unless we've got no child columns 427 // any longer, in which case we should switch to a TableColumnHeader 428 // instead 429 if (getColumns().isEmpty()) { 430 // iterate through all current headers, telling them to clean up 431 for (int i = 0; i < getColumnHeaders().size(); i++) { 432 TableColumnHeader header = getColumnHeaders().get(i); 433 header.dispose(); 434 } 435 436 // switch out to be a TableColumn instead, if we have a parent header 437 NestedTableColumnHeader parentHeader = getParentHeader(); 438 if (parentHeader != null) { 439 List<TableColumnHeader> parentColumnHeaders = parentHeader.getColumnHeaders(); 440 int index = parentColumnHeaders.indexOf(this); 441 if (index >= 0 && index < parentColumnHeaders.size()) { 442 parentColumnHeaders.set(index, createColumnHeader(getTableColumn())); 443 } 444 } else { 445 // otherwise just remove all the columns 446 columnHeaders.clear(); 447 } 448 } else { 449 List<TableColumnHeader> oldHeaders = new ArrayList<>(getColumnHeaders()); 450 List<TableColumnHeader> newHeaders = new ArrayList<>(); 451 452 for (int i = 0; i < getColumns().size(); i++) { 453 TableColumnBase<?,?> column = getColumns().get(i); 454 if (column == null || ! column.isVisible()) continue; 455 456 // check if the header already exists and reuse it 457 boolean found = false; 458 for (int j = 0; j < oldHeaders.size(); j++) { 459 TableColumnHeader oldColumn = oldHeaders.get(j); 460 if (oldColumn.represents(column)) { 461 newHeaders.add(oldColumn); 462 found = true; 463 break; 464 } 465 } 466 467 // otherwise create a new table column header 468 if (!found) { 469 newHeaders.add(createColumnHeader(column)); 470 } 471 } 472 473 columnHeaders.setAll(newHeaders); 474 475 // dispose all old headers 476 oldHeaders.removeAll(newHeaders); 477 for (int i = 0; i < oldHeaders.size(); i++) { 478 oldHeaders.get(i).dispose(); 479 } 480 } 481 482 // update the content 483 updateContent(); 484 485 // RT-33596: Do CSS now, as we are in the middle of layout pass and the headers are new Nodes w/o CSS done 486 for (TableColumnHeader header : getColumnHeaders()) { 487 header.applyCss(); 488 } 489 } 490 491 // Used to test whether this column header properly represents the given column. 492 // In particular, whether it has child column headers for all child columns 493 boolean represents(TableColumnBase<?, ?> column) { 494 if (column.getColumns().isEmpty()) { 495 // this column has no children, but we are in a NestedTableColumnHeader instance, 496 // so the match is bad. 497 return false; 498 } 499 500 if (column != getTableColumn()) { 501 return false; 502 } 503 504 final int columnCount = column.getColumns().size(); 505 final int headerCount = getColumnHeaders().size(); 506 if (columnCount != headerCount) { 507 return false; 508 } 509 510 for (int i = 0; i < columnCount; i++) { 511 // we expect the order of all children to match the order of the headers 512 TableColumnBase<?,?> childColumn = column.getColumns().get(i); 513 TableColumnHeader childHeader = getColumnHeaders().get(i); 514 if (!childHeader.represents(childColumn)) { 515 return false; 516 } 517 } 518 return true; 519 } 520 521 /** {@inheritDoc} */ 522 @Override double getDragRectHeight() { 523 return label.prefHeight(-1); 524 } 525 526 void setHeadersNeedUpdate() { 527 updateColumns = true; 528 529 // go through children columns - they should update too 530 for (int i = 0; i < getColumnHeaders().size(); i++) { 531 TableColumnHeader header = getColumnHeaders().get(i); 532 if (header instanceof NestedTableColumnHeader) { 533 ((NestedTableColumnHeader)header).setHeadersNeedUpdate(); 534 } 535 } 536 requestLayout(); 537 } 538 539 private void updateContent() { 540 // create a temporary list so we only do addAll into the main content 541 // observableArrayList once. 542 final List<Node> content = new ArrayList<Node>(); 543 544 // the label is the region that sits above the children columns 545 content.add(label); 546 547 // all children columns 548 content.addAll(getColumnHeaders()); 549 550 // Small transparent overlays that sit at the start and end of each 551 // column to intercept user drag gestures to enable column resizing. 552 if (isColumnResizingEnabled()) { 553 rebuildDragRects(); 554 content.addAll(dragRects.values()); 555 } 556 557 getChildren().setAll(content); 558 } 559 560 private void rebuildDragRects() { 561 if (! isColumnResizingEnabled()) return; 562 563 getChildren().removeAll(dragRects.values()); 564 565 for (Rectangle rect : dragRects.values()) { 566 rect.visibleProperty().unbind(); 567 } 568 dragRects.clear(); 569 570 List<? extends TableColumnBase> columns = getColumns(); 571 572 if (columns == null) { 573 return; 574 } 575 576 boolean isConstrainedResize = false; 577 TableViewSkinBase tableSkin = getTableSkin(); 578 Callback<ResizeFeaturesBase,Boolean> columnResizePolicy = TableSkinUtils.columnResizePolicyProperty(tableSkin).get(); 579 if (columnResizePolicy != null) { 580 isConstrainedResize = 581 tableSkin instanceof TableViewSkin ? TableView.CONSTRAINED_RESIZE_POLICY.equals(columnResizePolicy) : 582 tableSkin instanceof TreeTableViewSkin ? TreeTableView.CONSTRAINED_RESIZE_POLICY.equals(columnResizePolicy) : 583 false; 584 } 585 586 // RT-32547 - don't show resize cursor when in constrained resize mode 587 // and there is only one column 588 if (isConstrainedResize && TableSkinUtils.getVisibleLeafColumns(tableSkin).size() == 1) { 589 return; 590 } 591 592 for (int col = 0; col < columns.size(); col++) { 593 if (isConstrainedResize && col == getColumns().size() - 1) { 594 break; 595 } 596 597 final TableColumnBase c = columns.get(col); 598 final Rectangle rect = new Rectangle(); 599 rect.getProperties().put(TABLE_COLUMN_KEY, c); 600 rect.getProperties().put(TABLE_COLUMN_HEADER_KEY, this); 601 rect.setWidth(DRAG_RECT_WIDTH); 602 rect.setHeight(getHeight() - label.getHeight()); 603 rect.setFill(Color.TRANSPARENT); 604 rect.visibleProperty().bind(c.visibleProperty().and(c.resizableProperty())); 605 rect.setOnMousePressed(rectMousePressed); 606 rect.setOnMouseDragged(rectMouseDragged); 607 rect.setOnMouseReleased(rectMouseReleased); 608 rect.setOnMouseEntered(rectCursorChangeListener); 609 rect.setOnMouseExited(rectCursorChangeListener); 610 611 dragRects.put(c, rect); 612 } 613 } 614 615 private void checkState() { 616 if (updateColumns) { 617 updateTableColumnHeaders(); 618 updateColumns = false; 619 } 620 } 621 622 private TableColumnHeader createColumnHeader(TableColumnBase col) { 623 TableColumnHeader newCol = createTableColumnHeader(col); 624 newCol.setTableHeaderRow(getTableHeaderRow()); 625 newCol.setParentHeader(this); 626 return newCol; 627 } 628 629 630 631 /*************************************************************************** 632 * * 633 * Private Implementation: Column Resizing * 634 * * 635 **************************************************************************/ 636 637 private boolean isColumnResizingEnabled() { 638 // this used to check if ! PlatformUtil.isEmbedded(), but has been changed 639 // to always return true (for now), as we want to support column resizing 640 // everywhere 641 return true; 642 } 643 644 private void columnResizingStarted(double startX) { 645 setCursor(Cursor.H_RESIZE); 646 columnReorderLine.setLayoutX(startX); 647 } 648 649 private void columnResizing(TableColumnBase col, MouseEvent me) { 650 double draggedX = me.getSceneX() - dragAnchorX; 651 if (getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { 652 draggedX = -draggedX; 653 } 654 double delta = draggedX - lastX; 655 boolean allowed = TableSkinUtils.resizeColumn(getTableSkin(), col, delta); 656 if (allowed) { 657 lastX = draggedX; 658 } 659 } 660 661 private void columnResizingComplete(TableColumnBase col, MouseEvent me) { 662 setCursor(null); 663 columnReorderLine.setTranslateX(0.0F); 664 columnReorderLine.setLayoutX(0.0F); 665 lastX = 0.0F; 666 } 667 }