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