1 /* 2 * Copyright (c) 2010, 2018, 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.behavior.ComboBoxBaseBehavior; 29 import com.sun.javafx.scene.control.behavior.ComboBoxListViewBehavior; 30 31 import java.util.List; 32 33 import javafx.beans.InvalidationListener; 34 import javafx.beans.WeakInvalidationListener; 35 import javafx.beans.property.BooleanProperty; 36 import javafx.beans.property.SimpleBooleanProperty; 37 import javafx.collections.FXCollections; 38 import javafx.collections.ListChangeListener; 39 import javafx.collections.ObservableList; 40 import javafx.collections.WeakListChangeListener; 41 import javafx.css.PseudoClass; 42 import javafx.event.ActionEvent; 43 import javafx.event.EventTarget; 44 import javafx.scene.AccessibleAttribute; 45 import javafx.scene.AccessibleRole; 46 import javafx.scene.Node; 47 import javafx.scene.Parent; 48 import javafx.scene.control.ComboBox; 49 import javafx.scene.control.Control; 50 import javafx.scene.control.ListCell; 51 import javafx.scene.control.ListView; 52 import javafx.scene.control.SelectionMode; 53 import javafx.scene.control.SelectionModel; 54 import javafx.scene.control.TextField; 55 import javafx.scene.input.*; 56 import javafx.util.Callback; 57 import javafx.util.StringConverter; 58 59 /** 60 * Default skin implementation for the {@link ComboBox} control. 61 * 62 * @see ComboBox 63 * @since 9 64 */ 65 public class ComboBoxListViewSkin<T> extends ComboBoxPopupControl<T> { 66 67 /*************************************************************************** 68 * * 69 * Static fields * 70 * * 71 **************************************************************************/ 72 73 // By default we measure the width of all cells in the ListView. If this 74 // is too burdensome, the developer may set a property in the ComboBox 75 // properties map with this key to specify the number of rows to measure. 76 // This may one day become a property on the ComboBox itself. 77 private static final String COMBO_BOX_ROWS_TO_MEASURE_WIDTH_KEY = "comboBoxRowsToMeasureWidth"; 78 79 80 81 /*************************************************************************** 82 * * 83 * Private fields * 84 * * 85 **************************************************************************/ 86 87 private final ComboBox<T> comboBox; 88 private ObservableList<T> comboBoxItems; 89 90 private ListCell<T> buttonCell; 91 private Callback<ListView<T>, ListCell<T>> cellFactory; 92 93 private final ListView<T> listView; 94 private ObservableList<T> listViewItems; 95 96 private boolean listSelectionLock = false; 97 private boolean listViewSelectionDirty = false; 98 99 private final ComboBoxListViewBehavior behavior; 100 101 102 103 /*************************************************************************** 104 * * 105 * Listeners * 106 * * 107 **************************************************************************/ 108 109 private boolean itemCountDirty; 110 private final ListChangeListener<T> listViewItemsListener = new ListChangeListener<T>() { 111 @Override public void onChanged(ListChangeListener.Change<? extends T> c) { 112 itemCountDirty = true; 113 getSkinnable().requestLayout(); 114 } 115 }; 116 117 private final InvalidationListener itemsObserver; 118 119 private final WeakListChangeListener<T> weakListViewItemsListener = 120 new WeakListChangeListener<T>(listViewItemsListener); 121 122 123 /*************************************************************************** 124 * * 125 * Constructors * 126 * * 127 **************************************************************************/ 128 129 /** 130 * Creates a new ComboBoxListViewSkin instance, installing the necessary child 131 * nodes into the Control {@link Control#getChildren() children} list, as 132 * well as the necessary input mappings for handling key, mouse, etc events. 133 * 134 * @param control The control that this skin should be installed onto. 135 */ 136 public ComboBoxListViewSkin(final ComboBox<T> control) { 137 super(control); 138 139 // install default input map for the control 140 this.behavior = new ComboBoxListViewBehavior<>(control); 141 // control.setInputMap(behavior.getInputMap()); 142 143 this.comboBox = control; 144 updateComboBoxItems(); 145 146 itemsObserver = observable -> { 147 updateComboBoxItems(); 148 updateListViewItems(); 149 }; 150 control.itemsProperty().addListener(new WeakInvalidationListener(itemsObserver)); 151 152 // listview for popup 153 this.listView = createListView(); 154 155 // Fix for RT-21207. Additional code related to this bug is further below. 156 this.listView.setManaged(false); 157 getChildren().add(listView); 158 // -- end of fix 159 160 updateListViewItems(); 161 updateCellFactory(); 162 163 updateButtonCell(); 164 165 // Fix for RT-19431 (also tested via ComboBoxListViewSkinTest) 166 updateValue(); 167 168 registerChangeListener(control.itemsProperty(), e -> { 169 updateComboBoxItems(); 170 updateListViewItems(); 171 }); 172 registerChangeListener(control.promptTextProperty(), e -> updateDisplayNode()); 173 registerChangeListener(control.cellFactoryProperty(), e -> updateCellFactory()); 174 registerChangeListener(control.visibleRowCountProperty(), e -> { 175 if (listView == null) return; 176 listView.requestLayout(); 177 }); 178 registerChangeListener(control.converterProperty(), e -> updateListViewItems()); 179 registerChangeListener(control.buttonCellProperty(), e -> { 180 updateButtonCell(); 181 updateDisplayArea(); 182 }); 183 registerChangeListener(control.valueProperty(), e -> { 184 updateValue(); 185 control.fireEvent(new ActionEvent()); 186 }); 187 registerChangeListener(control.editableProperty(), e -> updateEditable()); 188 189 // Refer to JDK-8095306 190 if (comboBox.isShowing()) { 191 show(); 192 } 193 } 194 195 196 197 /*************************************************************************** 198 * * 199 * Properties * 200 * * 201 **************************************************************************/ 202 203 /** 204 * By default this skin hides the popup whenever the ListView is clicked in. 205 * By setting hideOnClick to false, the popup will not be hidden when the 206 * ListView is clicked in. This is beneficial in some scenarios (for example, 207 * when the ListView cells have checkboxes). 208 */ 209 // --- hide on click 210 private final BooleanProperty hideOnClick = new SimpleBooleanProperty(this, "hideOnClick", true); 211 public final BooleanProperty hideOnClickProperty() { 212 return hideOnClick; 213 } 214 public final boolean isHideOnClick() { 215 return hideOnClick.get(); 216 } 217 public final void setHideOnClick(boolean value) { 218 hideOnClick.set(value); 219 } 220 221 222 223 /*************************************************************************** 224 * * 225 * Public API * 226 * * 227 **************************************************************************/ 228 229 /** {@inheritDoc} */ 230 @Override public void dispose() { 231 super.dispose(); 232 233 if (behavior != null) { 234 behavior.dispose(); 235 } 236 } 237 238 /** {@inheritDoc} */ 239 @Override protected TextField getEditor() { 240 // Return null if editable is false, even if the ComboBox has an editor set. 241 // Use getSkinnable() here because this method is called from the super 242 // constructor before comboBox is initialized. 243 return getSkinnable().isEditable() ? ((ComboBox)getSkinnable()).getEditor() : null; 244 } 245 246 /** {@inheritDoc} */ 247 @Override protected StringConverter<T> getConverter() { 248 return ((ComboBox)getSkinnable()).getConverter(); 249 } 250 251 /** {@inheritDoc} */ 252 @Override public Node getDisplayNode() { 253 Node displayNode; 254 if (comboBox.isEditable()) { 255 displayNode = getEditableInputNode(); 256 } else { 257 displayNode = buttonCell; 258 } 259 260 updateDisplayNode(); 261 262 return displayNode; 263 } 264 265 /** {@inheritDoc} */ 266 @Override public Node getPopupContent() { 267 return listView; 268 } 269 270 /** {@inheritDoc} */ 271 @Override protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { 272 reconfigurePopup(); 273 return 50; 274 } 275 276 /** {@inheritDoc} */ 277 @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { 278 double superPrefWidth = super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset); 279 double listViewWidth = listView.prefWidth(height); 280 double pw = Math.max(superPrefWidth, listViewWidth); 281 282 reconfigurePopup(); 283 284 return pw; 285 } 286 287 /** {@inheritDoc} */ 288 @Override protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { 289 reconfigurePopup(); 290 return super.computeMaxWidth(height, topInset, rightInset, bottomInset, leftInset); 291 } 292 293 /** {@inheritDoc} */ 294 @Override protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { 295 reconfigurePopup(); 296 return super.computeMinHeight(width, topInset, rightInset, bottomInset, leftInset); 297 } 298 299 /** {@inheritDoc} */ 300 @Override protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { 301 reconfigurePopup(); 302 return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset); 303 } 304 305 /** {@inheritDoc} */ 306 @Override protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { 307 reconfigurePopup(); 308 return super.computeMaxHeight(width, topInset, rightInset, bottomInset, leftInset); 309 } 310 311 /** {@inheritDoc} */ 312 @Override protected void layoutChildren(final double x, final double y, 313 final double w, final double h) { 314 if (listViewSelectionDirty) { 315 try { 316 listSelectionLock = true; 317 T item = comboBox.getSelectionModel().getSelectedItem(); 318 listView.getSelectionModel().clearSelection(); 319 listView.getSelectionModel().select(item); 320 } finally { 321 listSelectionLock = false; 322 listViewSelectionDirty = false; 323 } 324 } 325 326 super.layoutChildren(x, y, w, h); 327 } 328 329 330 331 /*************************************************************************** 332 * * 333 * Private methods * 334 * * 335 **************************************************************************/ 336 337 /** {@inheritDoc} */ 338 @Override void updateDisplayNode() { 339 if (getEditor() != null) { 340 super.updateDisplayNode(); 341 } else { 342 T value = comboBox.getValue(); 343 int index = getIndexOfComboBoxValueInItemsList(); 344 if (index > -1) { 345 buttonCell.setItem(null); 346 buttonCell.updateIndex(index); 347 } else { 348 // RT-21336 Show the ComboBox value even though it doesn't 349 // exist in the ComboBox items list (part two of fix) 350 buttonCell.updateIndex(-1); 351 boolean empty = updateDisplayText(buttonCell, value, false); 352 353 // Note that empty boolean collected above. This is used to resolve 354 // RT-27834, where we were getting different styling based on whether 355 // the cell was updated via the updateIndex method above, or just 356 // by directly updating the text. We fake the pseudoclass state 357 // for empty, filled, and selected here. 358 buttonCell.pseudoClassStateChanged(PSEUDO_CLASS_EMPTY, empty); 359 buttonCell.pseudoClassStateChanged(PSEUDO_CLASS_FILLED, !empty); 360 buttonCell.pseudoClassStateChanged(PSEUDO_CLASS_SELECTED, true); 361 } 362 } 363 } 364 365 /** {@inheritDoc} */ 366 @Override ComboBoxBaseBehavior getBehavior() { 367 return behavior; 368 } 369 370 private void updateComboBoxItems() { 371 comboBoxItems = comboBox.getItems(); 372 comboBoxItems = comboBoxItems == null ? FXCollections.<T>emptyObservableList() : comboBoxItems; 373 } 374 375 private void updateListViewItems() { 376 if (listViewItems != null) { 377 listViewItems.removeListener(weakListViewItemsListener); 378 } 379 380 this.listViewItems = comboBoxItems; 381 listView.setItems(listViewItems); 382 383 if (listViewItems != null) { 384 listViewItems.addListener(weakListViewItemsListener); 385 } 386 387 itemCountDirty = true; 388 getSkinnable().requestLayout(); 389 } 390 391 private void updateValue() { 392 T newValue = comboBox.getValue(); 393 394 SelectionModel<T> listViewSM = listView.getSelectionModel(); 395 396 // RT-22386: We need to test to see if the value is in the comboBox 397 // items list. If it isn't, then we should clear the listview 398 // selection 399 final int indexOfNewValue = getIndexOfComboBoxValueInItemsList(); 400 401 if (newValue == null && indexOfNewValue == -1) { 402 listViewSM.clearSelection(); 403 } else { 404 if (indexOfNewValue == -1) { 405 listSelectionLock = true; 406 listViewSM.clearSelection(); 407 listSelectionLock = false; 408 } else { 409 int index = comboBox.getSelectionModel().getSelectedIndex(); 410 if (index >= 0 && index < comboBoxItems.size()) { 411 T itemsObj = comboBoxItems.get(index); 412 if ((itemsObj != null && itemsObj.equals(newValue)) || (itemsObj == null && newValue == null)) { 413 listViewSM.select(index); 414 } else { 415 listViewSM.select(newValue); 416 } 417 } else { 418 // just select the first instance of newValue in the list 419 int listViewIndex = comboBoxItems.indexOf(newValue); 420 if (listViewIndex == -1) { 421 // RT-21336 Show the ComboBox value even though it doesn't 422 // exist in the ComboBox items list (part one of fix) 423 updateDisplayNode(); 424 } else { 425 listViewSM.select(listViewIndex); 426 } 427 } 428 } 429 } 430 } 431 432 // return a boolean to indicate that the cell is empty (and therefore not filled) 433 private boolean updateDisplayText(ListCell<T> cell, T item, boolean empty) { 434 if (empty) { 435 if (cell == null) return true; 436 cell.setGraphic(null); 437 cell.setText(null); 438 return true; 439 } else if (item instanceof Node) { 440 Node currentNode = cell.getGraphic(); 441 Node newNode = (Node) item; 442 if (currentNode == null || ! currentNode.equals(newNode)) { 443 cell.setText(null); 444 cell.setGraphic(newNode); 445 } 446 return newNode == null; 447 } else { 448 // run item through StringConverter if it isn't null 449 final StringConverter<T> c = comboBox.getConverter(); 450 final String promptText = comboBox.getPromptText(); 451 String s = item == null && promptText != null ? promptText : 452 c == null ? (item == null ? null : item.toString()) : c.toString(item); 453 cell.setText(s); 454 cell.setGraphic(null); 455 return s == null || s.isEmpty(); 456 } 457 } 458 459 private int getIndexOfComboBoxValueInItemsList() { 460 T value = comboBox.getValue(); 461 int index = comboBoxItems.indexOf(value); 462 return index; 463 } 464 465 private void updateButtonCell() { 466 buttonCell = comboBox.getButtonCell() != null ? 467 comboBox.getButtonCell() : getDefaultCellFactory().call(listView); 468 buttonCell.setMouseTransparent(true); 469 buttonCell.updateListView(listView); 470 471 // As long as the screen-reader is concerned this node is not a list item. 472 // This matters because the screen-reader counts the number of list item 473 // within combo and speaks it to the user. 474 buttonCell.setAccessibleRole(AccessibleRole.NODE); 475 } 476 477 private void updateCellFactory() { 478 Callback<ListView<T>, ListCell<T>> cf = comboBox.getCellFactory(); 479 cellFactory = cf != null ? cf : getDefaultCellFactory(); 480 listView.setCellFactory(cellFactory); 481 } 482 483 private Callback<ListView<T>, ListCell<T>> getDefaultCellFactory() { 484 return new Callback<ListView<T>, ListCell<T>>() { 485 @Override public ListCell<T> call(ListView<T> listView) { 486 return new ListCell<T>() { 487 @Override public void updateItem(T item, boolean empty) { 488 super.updateItem(item, empty); 489 updateDisplayText(this, item, empty); 490 } 491 }; 492 } 493 }; 494 } 495 496 private ListView<T> createListView() { 497 final ListView<T> _listView = new ListView<T>() { 498 499 { 500 getProperties().put("selectFirstRowByDefault", false); 501 } 502 503 @Override protected double computeMinHeight(double width) { 504 return 30; 505 } 506 507 @Override protected double computePrefWidth(double height) { 508 double pw; 509 if (getSkin() instanceof ListViewSkin) { 510 ListViewSkin<?> skin = (ListViewSkin<?>)getSkin(); 511 if (itemCountDirty) { 512 skin.updateItemCount(); 513 itemCountDirty = false; 514 } 515 516 int rowsToMeasure = -1; 517 if (comboBox.getProperties().containsKey(COMBO_BOX_ROWS_TO_MEASURE_WIDTH_KEY)) { 518 rowsToMeasure = (Integer) comboBox.getProperties().get(COMBO_BOX_ROWS_TO_MEASURE_WIDTH_KEY); 519 } 520 521 pw = Math.max(comboBox.getWidth(), skin.getMaxCellWidth(rowsToMeasure) + 30); 522 } else { 523 pw = Math.max(100, comboBox.getWidth()); 524 } 525 526 // need to check the ListView pref height in the case that the 527 // placeholder node is showing 528 if (getItems().isEmpty() && getPlaceholder() != null) { 529 pw = Math.max(super.computePrefWidth(height), pw); 530 } 531 532 return Math.max(50, pw); 533 } 534 535 @Override protected double computePrefHeight(double width) { 536 return getListViewPrefHeight(); 537 } 538 }; 539 540 _listView.setId("list-view"); 541 _listView.placeholderProperty().bind(comboBox.placeholderProperty()); 542 _listView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); 543 _listView.setFocusTraversable(false); 544 545 _listView.getSelectionModel().selectedIndexProperty().addListener(o -> { 546 if (listSelectionLock) return; 547 int index = listView.getSelectionModel().getSelectedIndex(); 548 comboBox.getSelectionModel().select(index); 549 updateDisplayNode(); 550 comboBox.notifyAccessibleAttributeChanged(AccessibleAttribute.TEXT); 551 }); 552 553 comboBox.getSelectionModel().selectedItemProperty().addListener(o -> { 554 listViewSelectionDirty = true; 555 }); 556 557 _listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> { 558 // RT-18672: Without checking if the user is clicking in the 559 // scrollbar area of the ListView, the comboBox will hide. Therefore, 560 // we add the check below to prevent this from happening. 561 EventTarget target = t.getTarget(); 562 if (target instanceof Parent) { 563 List<String> s = ((Parent) target).getStyleClass(); 564 if (s.contains("thumb") 565 || s.contains("track") 566 || s.contains("decrement-arrow") 567 || s.contains("increment-arrow")) { 568 return; 569 } 570 } 571 572 if (isHideOnClick()) { 573 comboBox.hide(); 574 } 575 }); 576 577 _listView.setOnKeyPressed(t -> { 578 // TODO move to behavior, when (or if) this class becomes a SkinBase 579 if (t.getCode() == KeyCode.ENTER || 580 t.getCode() == KeyCode.SPACE || 581 t.getCode() == KeyCode.ESCAPE) { 582 comboBox.hide(); 583 } 584 }); 585 586 return _listView; 587 } 588 589 private double getListViewPrefHeight() { 590 double ph; 591 if (listView.getSkin() instanceof VirtualContainerBase) { 592 int maxRows = comboBox.getVisibleRowCount(); 593 VirtualContainerBase<?,?> skin = (VirtualContainerBase<?,?>)listView.getSkin(); 594 ph = skin.getVirtualFlowPreferredHeight(maxRows); 595 } else { 596 double ch = comboBoxItems.size() * 25; 597 ph = Math.min(ch, 200); 598 } 599 600 return ph; 601 } 602 603 604 605 /************************************************************************** 606 * 607 * API for testing 608 * 609 *************************************************************************/ 610 611 ListView<T> getListView() { 612 return listView; 613 } 614 615 616 617 618 /*************************************************************************** 619 * * 620 * Stylesheet Handling * 621 * * 622 **************************************************************************/ 623 624 // These three pseudo class states are duplicated from Cell 625 private static final PseudoClass PSEUDO_CLASS_SELECTED = 626 PseudoClass.getPseudoClass("selected"); 627 private static final PseudoClass PSEUDO_CLASS_EMPTY = 628 PseudoClass.getPseudoClass("empty"); 629 private static final PseudoClass PSEUDO_CLASS_FILLED = 630 PseudoClass.getPseudoClass("filled"); 631 632 633 /** {@inheritDoc} */ 634 @Override public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { 635 switch (attribute) { 636 case FOCUS_ITEM: { 637 if (comboBox.isShowing()) { 638 /* On Mac, for some reason, changing the selection on the list is not 639 * reported by VoiceOver the first time it shows. 640 * Note that this fix returns a child of the PopupWindow back to the main 641 * Stage, which doesn't seem to cause problems. 642 */ 643 return listView.queryAccessibleAttribute(attribute, parameters); 644 } 645 return null; 646 } 647 case TEXT: { 648 String accText = comboBox.getAccessibleText(); 649 if (accText != null && !accText.isEmpty()) return accText; 650 String title = comboBox.isEditable() ? getEditor().getText() : buttonCell.getText(); 651 if (title == null || title.isEmpty()) { 652 title = comboBox.getPromptText(); 653 } 654 return title; 655 } 656 case SELECTION_START: 657 return (getEditor() != null) ? getEditor().getSelection().getStart() : null; 658 case SELECTION_END: 659 return (getEditor() != null) ? getEditor().getSelection().getEnd() : null; 660 default: return super.queryAccessibleAttribute(attribute, parameters); 661 } 662 } 663 } 664