1 /* 2 * Copyright (c) 2011, 2015, 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.collections.annotations.ReturnsUnmodifiableCollection; 29 import com.sun.javafx.scene.control.skin.Utils; 30 import javafx.beans.property.ObjectProperty; 31 import javafx.collections.WeakListChangeListener; 32 import java.util.ArrayList; 33 import java.util.List; 34 import java.util.Map; 35 import java.util.WeakHashMap; 36 import java.util.concurrent.atomic.AtomicBoolean; 37 38 import javafx.collections.FXCollections; 39 import javafx.collections.ListChangeListener; 40 import javafx.collections.ObservableList; 41 import javafx.event.EventHandler; 42 import javafx.geometry.NodeOrientation; 43 import javafx.scene.Cursor; 44 import javafx.scene.Node; 45 import javafx.scene.control.*; 46 import javafx.scene.input.MouseEvent; 47 import javafx.scene.paint.Color; 48 import javafx.scene.shape.Rectangle; 49 import javafx.util.Callback; 50 51 /** 52 * <p>This class is used to construct the header of a TableView. We take the approach 53 * that every TableView header is nested - even if it isn't. This allows for us 54 * to use the same code for building a single row of TableColumns as we would 55 * with a heavily nested sequences of TableColumns. Because of this, the 56 * TableHeaderRow class consists of just one instance of a NestedTableColumnHeader. 57 * 58 * @since 9 59 * @see TableColumnHeader 60 * @see TableHeaderRow 61 * @see TableColumnBase 62 */ 63 public class NestedTableColumnHeader extends TableColumnHeader { 64 65 /*************************************************************************** 66 * * 67 * Static Fields * 68 * * 69 **************************************************************************/ 70 71 private static final int DRAG_RECT_WIDTH = 4; 72 73 private static final String TABLE_COLUMN_KEY = "TableColumn"; 74 private static final String TABLE_COLUMN_HEADER_KEY = "TableColumnHeader"; 75 76 77 78 /*************************************************************************** 79 * * 80 * Private Fields * 81 * * 82 **************************************************************************/ 83 84 /** 85 * Represents the actual columns directly contained in this nested column. 86 * It does NOT include ANY of the children of these columns, if any exist. 87 */ 88 private ObservableList<? extends TableColumnBase> columns; 89 90 private TableColumnHeader label; 91 92 private ObservableList<TableColumnHeader> columnHeaders; 93 private ObservableList<TableColumnHeader> unmodifiableColumnHeaders; 94 95 // used for column resizing 96 private double lastX = 0.0F; 97 private double dragAnchorX = 0.0; 98 99 // drag rectangle overlays 100 private Map<TableColumnBase<?,?>, Rectangle> dragRects = new WeakHashMap<>(); 101 102 boolean updateColumns = true; 103 104 105 106 /*************************************************************************** 107 * * 108 * Constructor * 109 * * 110 **************************************************************************/ 111 112 /** 113 * Creates a new NestedTableColumnHeader instance to visually represent the given 114 * {@link TableColumnBase} instance. 115 * 116 * @param skin The skin used by the UI control. 117 * @param tc The table column to be visually represented by this instance. 118 */ 119 public NestedTableColumnHeader(final TableViewSkinBase skin, final TableColumnBase tc) { 120 super(skin, tc); 121 122 getStyleClass().setAll("nested-column-header"); 123 setFocusTraversable(false); 124 125 // init UI 126 label = new TableColumnHeader(skin, 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 changeListenerHandler.registerChangeListener(TableSkinUtils.columnResizePolicyProperty(skin), e -> updateContent()); 137 } 138 139 140 141 /*************************************************************************** 142 * * 143 * Listeners * 144 * * 145 **************************************************************************/ 146 147 private final ListChangeListener<TableColumnBase> columnsListener = c -> { 148 setHeadersNeedUpdate(); 149 }; 150 151 private final WeakListChangeListener weakColumnsListener = 152 new WeakListChangeListener(columnsListener); 153 154 private static final EventHandler<MouseEvent> rectMousePressed = me -> { 155 Rectangle rect = (Rectangle) me.getSource(); 156 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 157 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 158 159 if (! header.isColumnResizingEnabled()) return; 160 161 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 162 // can be received by both nodes, leading to less than ideal UX, hence the check here. 163 if (header.getTableHeaderRow().columnDragLock) return; 164 165 if (me.isConsumed()) return; 166 me.consume(); 167 168 if (me.getClickCount() == 2 && me.isPrimaryButtonDown()) { 169 // the user wants to resize the column such that its 170 // width is equal to the widest element in the column 171 TableSkinUtils.resizeColumnToFitContent(header.getTableViewSkin(), column, -1); 172 } else { 173 // rather than refer to the rect variable, we just grab 174 // it from the source to prevent a small memory leak. 175 Rectangle innerRect = (Rectangle) me.getSource(); 176 double startX = header.getTableHeaderRow().sceneToLocal(innerRect.localToScene(innerRect.getBoundsInLocal())).getMinX() + 2; 177 header.dragAnchorX = me.getSceneX(); 178 header.columnResizingStarted(startX); 179 } 180 }; 181 182 private static final EventHandler<MouseEvent> rectMouseDragged = me -> { 183 Rectangle rect = (Rectangle) me.getSource(); 184 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 185 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 186 187 if (! header.isColumnResizingEnabled()) return; 188 189 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 190 // can be received by both nodes, leading to less than ideal UX, hence the check here. 191 if (header.getTableHeaderRow().columnDragLock) return; 192 193 if (me.isConsumed()) return; 194 me.consume(); 195 196 header.columnResizing(column, me); 197 }; 198 199 private static final EventHandler<MouseEvent> rectMouseReleased = me -> { 200 Rectangle rect = (Rectangle) me.getSource(); 201 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 202 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 203 204 if (! header.isColumnResizingEnabled()) return; 205 206 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 207 // can be received by both nodes, leading to less than ideal UX, hence the check here. 208 if (header.getTableHeaderRow().columnDragLock) return; 209 210 if (me.isConsumed()) return; 211 me.consume(); 212 213 header.columnResizingComplete(column, me); 214 }; 215 216 private static final EventHandler<MouseEvent> rectCursorChangeListener = me -> { 217 Rectangle rect = (Rectangle) me.getSource(); 218 TableColumnBase column = (TableColumnBase) rect.getProperties().get(TABLE_COLUMN_KEY); 219 NestedTableColumnHeader header = (NestedTableColumnHeader) rect.getProperties().get(TABLE_COLUMN_HEADER_KEY); 220 221 // column reordering takes precedence over column resizing, but sometimes the mouse dragged events 222 // can be received by both nodes, leading to less than ideal UX, hence the check here. 223 if (header.getTableHeaderRow().columnDragLock) return; 224 225 if (header.getCursor() == null) { // If there's a cursor for the whole header, don't override it 226 rect.setCursor(header.isColumnResizingEnabled() && rect.isHover() && 227 column.isResizable() ? Cursor.H_RESIZE : null); 228 } 229 }; 230 231 232 233 /*************************************************************************** 234 * * 235 * Public Methods * 236 * * 237 **************************************************************************/ 238 239 /** {@inheritDoc} */ 240 @Override void dispose() { 241 super.dispose(); 242 243 if (label != null) { 244 label.dispose(); 245 } 246 247 if (getColumns() != null) { 248 getColumns().removeListener(weakColumnsListener); 249 } 250 251 for (int i = 0; i < getColumnHeaders().size(); i++) { 252 TableColumnHeader header = getColumnHeaders().get(i); 253 header.dispose(); 254 } 255 256 for (Rectangle rect : dragRects.values()) { 257 if (rect != null) { 258 rect.visibleProperty().unbind(); 259 } 260 } 261 dragRects.clear(); 262 getChildren().clear(); 263 264 changeListenerHandler.dispose(); 265 } 266 267 /** 268 * Returns an unmodifiable list of the {@link TableColumnHeader} instances 269 * that are children of this NestedTableColumnHeader. 270 */ 271 @ReturnsUnmodifiableCollection 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 int pos = 0; 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 = snapSizeX(n.prefWidth(-1)); 301 // double prefHeight = n.prefHeight(-1); 302 303 // position the column header in the default location... 304 n.resize(prefWidth, snapSizeY(h - labelHeight)); 305 n.relocate(x, labelHeight + snappedTopInset()); 306 307 // // ...but, if there are no children of this column, we should ensure 308 // // that it is resized vertically such that it goes to the very 309 // // bottom of the table header row. 310 // if (getTableHeaderRow() != null && n.getCol().getColumns().isEmpty()) { 311 // Bounds bounds = getTableHeaderRow().sceneToLocal(n.localToScene(n.getBoundsInLocal())); 312 // prefHeight = getTableHeaderRow().getHeight() - bounds.getMinY(); 313 // n.resize(prefWidth, prefHeight); 314 // } 315 316 // shuffle along the x-axis appropriately 317 x += prefWidth; 318 319 // position drag overlay to intercept column resize requests 320 Rectangle dragRect = dragRects.get(n.getTableColumn()); 321 if (dragRect != null) { 322 dragRect.setHeight(n.getDragRectHeight()); 323 dragRect.relocate(x - DRAG_RECT_WIDTH / 2, snappedTopInset() + labelHeight); 324 } 325 } 326 } 327 328 // sum up all children columns 329 /** {@inheritDoc} */ 330 @Override protected double computePrefWidth(double height) { 331 checkState(); 332 333 double width = 0.0F; 334 335 if (getColumns() != null) { 336 for (TableColumnHeader c : getColumnHeaders()) { 337 if (c.isVisible()) { 338 width += snapSizeX(c.computePrefWidth(height)); 339 } 340 } 341 } 342 343 return width; 344 } 345 346 /** {@inheritDoc} */ 347 @Override protected double computePrefHeight(double width) { 348 checkState(); 349 350 double height = 0.0F; 351 352 if (getColumnHeaders() != null) { 353 for (TableColumnHeader n : getColumnHeaders()) { 354 height = Math.max(height, n.prefHeight(-1)); 355 } 356 } 357 358 return height + label.prefHeight(-1) + snappedTopInset() + snappedBottomInset(); 359 } 360 361 /** 362 * Creates a new TableColumnHeader instance for the given TableColumnBase instance. By default this method should 363 * not be overridden, but in some circumstances it makes sense (e.g. testing, or when extreme customization is desired). 364 * If the given TableColumnBase instance has child columns, then it is suggested to return a 365 * {@link NestedTableColumnHeader} instance instead. 366 * 367 * @return A new TableColumnHeader instance. 368 */ 369 protected TableColumnHeader createTableColumnHeader(TableColumnBase col) { 370 return col.getColumns().isEmpty() ? 371 new TableColumnHeader(getTableViewSkin(), col) : 372 new NestedTableColumnHeader(getTableViewSkin(), col); 373 } 374 375 376 377 /*************************************************************************** 378 * * 379 * Private Implementation * 380 * * 381 **************************************************************************/ 382 383 @Override void setTableHeaderRow(TableHeaderRow header) { 384 super.setTableHeaderRow(header); 385 386 label.setTableHeaderRow(header); 387 388 // tell all children columns what TableHeader they belong to 389 for (TableColumnHeader c : getColumnHeaders()) { 390 c.setTableHeaderRow(header); 391 } 392 } 393 394 @Override void setParentHeader(NestedTableColumnHeader parentHeader) { 395 super.setParentHeader(parentHeader); 396 label.setParentHeader(parentHeader); 397 } 398 399 ObservableList<? extends TableColumnBase> getColumns() { 400 return columns; 401 } 402 403 void setColumns(ObservableList<? extends TableColumnBase> newColumns) { 404 if (this.columns != null) { 405 this.columns.removeListener(weakColumnsListener); 406 } 407 408 this.columns = newColumns; 409 410 if (this.columns != null) { 411 this.columns.addListener(weakColumnsListener); 412 } 413 } 414 415 void updateTableColumnHeaders() { 416 // watching for changes to the view columns in either table or tableColumn. 417 if (getTableColumn() == null && getTableViewSkin() != null) { 418 setColumns(TableSkinUtils.getColumns(getTableViewSkin())); 419 } else if (getTableColumn() != null) { 420 setColumns(getTableColumn().getColumns()); 421 } 422 423 // update the column headers... 424 425 // iterate through all columns, unless we've got no child columns 426 // any longer, in which case we should switch to a TableColumnHeader 427 // instead 428 if (getColumns().isEmpty()) { 429 // iterate through all current headers, telling them to clean up 430 for (int i = 0; i < getColumnHeaders().size(); i++) { 431 TableColumnHeader header = getColumnHeaders().get(i); 432 header.dispose(); 433 } 434 435 // switch out to be a TableColumn instead, if we have a parent header 436 NestedTableColumnHeader parentHeader = getParentHeader(); 437 if (parentHeader != null) { 438 List<TableColumnHeader> parentColumnHeaders = parentHeader.getColumnHeaders(); 439 int index = parentColumnHeaders.indexOf(this); 440 if (index >= 0 && index < parentColumnHeaders.size()) { 441 parentColumnHeaders.set(index, createColumnHeader(getTableColumn())); 442 } 443 } else { 444 // otherwise just remove all the columns 445 columnHeaders.clear(); 446 } 447 } else { 448 List<TableColumnHeader> oldHeaders = new ArrayList<>(getColumnHeaders()); 449 List<TableColumnHeader> newHeaders = new ArrayList<>(); 450 451 for (int i = 0; i < getColumns().size(); i++) { 452 TableColumnBase<?,?> column = getColumns().get(i); 453 if (column == null || ! column.isVisible()) continue; 454 455 // check if the header already exists and reuse it 456 boolean found = false; 457 for (int j = 0; j < oldHeaders.size(); j++) { 458 TableColumnHeader oldColumn = oldHeaders.get(j); 459 if (oldColumn.represents(column)) { 460 newHeaders.add(oldColumn); 461 found = true; 462 break; 463 } 464 } 465 466 // otherwise create a new table column header 467 if (!found) { 468 newHeaders.add(createColumnHeader(column)); 469 } 470 } 471 472 columnHeaders.setAll(newHeaders); 473 474 // dispose all old headers 475 oldHeaders.removeAll(newHeaders); 476 for (int i = 0; i < oldHeaders.size(); i++) { 477 oldHeaders.get(i).dispose(); 478 } 479 } 480 481 // update the content 482 updateContent(); 483 484 // RT-33596: Do CSS now, as we are in the middle of layout pass and the headers are new Nodes w/o CSS done 485 for (TableColumnHeader header : getColumnHeaders()) { 486 header.applyCss(); 487 } 488 } 489 490 // Used to test whether this column header properly represents the given column. 491 // In particular, whether it has child column headers for all child columns 492 boolean represents(TableColumnBase<?, ?> column) { 493 if (column.getColumns().isEmpty()) { 494 // this column has no children, but we are in a NestedTableColumnHeader instance, 495 // so the match is bad. 496 return false; 497 } 498 499 if (column != getTableColumn()) { 500 return false; 501 } 502 503 final int columnCount = column.getColumns().size(); 504 final int headerCount = getColumnHeaders().size(); 505 if (columnCount != headerCount) { 506 return false; 507 } 508 509 for (int i = 0; i < columnCount; i++) { 510 // we expect the order of all children to match the order of the headers 511 TableColumnBase<?,?> childColumn = column.getColumns().get(i); 512 TableColumnHeader childHeader = getColumnHeaders().get(i); 513 if (!childHeader.represents(childColumn)) { 514 return false; 515 } 516 } 517 return true; 518 } 519 520 /** {@inheritDoc} */ 521 @Override double getDragRectHeight() { 522 return label.prefHeight(-1); 523 } 524 525 void setHeadersNeedUpdate() { 526 updateColumns = true; 527 528 // go through children columns - they should update too 529 for (int i = 0; i < getColumnHeaders().size(); i++) { 530 TableColumnHeader header = getColumnHeaders().get(i); 531 if (header instanceof NestedTableColumnHeader) { 532 ((NestedTableColumnHeader)header).setHeadersNeedUpdate(); 533 } 534 } 535 requestLayout(); 536 } 537 538 private void updateContent() { 539 // create a temporary list so we only do addAll into the main content 540 // observableArrayList once. 541 final List<Node> content = new ArrayList<Node>(); 542 543 // the label is the region that sits above the children columns 544 content.add(label); 545 546 // all children columns 547 content.addAll(getColumnHeaders()); 548 549 // Small transparent overlays that sit at the start and end of each 550 // column to intercept user drag gestures to enable column resizing. 551 if (isColumnResizingEnabled()) { 552 rebuildDragRects(); 553 content.addAll(dragRects.values()); 554 } 555 556 getChildren().setAll(content); 557 } 558 559 private void rebuildDragRects() { 560 if (! isColumnResizingEnabled()) return; 561 562 getChildren().removeAll(dragRects.values()); 563 564 for (Rectangle rect : dragRects.values()) { 565 rect.visibleProperty().unbind(); 566 } 567 dragRects.clear(); 568 569 List<? extends TableColumnBase> columns = getColumns(); 570 571 if (columns == null) { 572 return; 573 } 574 575 final TableViewSkinBase<?,?,?,?,?> skin = getTableViewSkin(); 576 577 boolean isConstrainedResize = false; 578 Callback<ResizeFeaturesBase,Boolean> columnResizePolicy = TableSkinUtils.columnResizePolicyProperty(skin).get(); 579 if (columnResizePolicy != null) { 580 isConstrainedResize = 581 skin instanceof TableViewSkin ? TableView.CONSTRAINED_RESIZE_POLICY.equals(columnResizePolicy) : 582 skin 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(skin).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(getTableViewSkin(), 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 }