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