1 /* 2 * Copyright (c) 2010, 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.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 -> updateButtonCell()); 180 registerChangeListener(control.valueProperty(), e -> { 181 updateValue(); 182 control.fireEvent(new ActionEvent()); 183 }); 184 registerChangeListener(control.editableProperty(), e -> updateEditable()); 185 186 // Refer to JDK-8095306 187 if (comboBox.isShowing()) { 188 show(); 189 } 190 } 191 192 193 194 /*************************************************************************** 195 * * 196 * Properties * 197 * * 198 **************************************************************************/ 199 200 /** 201 * By default this skin hides the popup whenever the ListView is clicked in. 202 * By setting hideOnClick to false, the popup will not be hidden when the 203 * ListView is clicked in. This is beneficial in some scenarios (for example, 204 * when the ListView cells have checkboxes). 205 */ 206 // --- hide on click 207 private final BooleanProperty hideOnClick = new SimpleBooleanProperty(this, "hideOnClick", true); 208 public final BooleanProperty hideOnClickProperty() { 209 return hideOnClick; 210 } 211 public final boolean isHideOnClick() { 212 return hideOnClick.get(); 213 } 214 public final void setHideOnClick(boolean value) { 215 hideOnClick.set(value); 216 } 217 218 219 220 /*************************************************************************** 221 * * 222 * Public API * 223 * * 224 **************************************************************************/ 225 226 /** {@inheritDoc} */ 227 @Override public void dispose() { 228 super.dispose(); 229 230 if (behavior != null) { 231 behavior.dispose(); 232 } 233 } 234 235 /** {@inheritDoc} */ 236 @Override protected TextField getEditor() { 237 // Return null if editable is false, even if the ComboBox has an editor set. 238 // Use getSkinnable() here because this method is called from the super 239 // constructor before comboBox is initialized. 240 return getSkinnable().isEditable() ? ((ComboBox)getSkinnable()).getEditor() : null; 241 } 242 243 /** {@inheritDoc} */ 244 @Override protected StringConverter<T> getConverter() { 245 return ((ComboBox)getSkinnable()).getConverter(); 246 } 247 248 /** {@inheritDoc} */ 249 @Override public Node getDisplayNode() { 250 Node displayNode; 251 if (comboBox.isEditable()) { 252 displayNode = getEditableInputNode(); 253 } else { 254 displayNode = buttonCell; 255 } 256 257 updateDisplayNode(); 258 259 return displayNode; 260 } 261 262 /** {@inheritDoc} */ 263 @Override public Node getPopupContent() { 264 return listView; 265 } 266 267 /** {@inheritDoc} */ 268 @Override protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { 269 reconfigurePopup(); 270 return 50; 271 } 272 273 /** {@inheritDoc} */ 274 @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { 275 double superPrefWidth = super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset); 276 double listViewWidth = listView.prefWidth(height); 277 double pw = Math.max(superPrefWidth, listViewWidth); 278 279 reconfigurePopup(); 280 281 return pw; 282 } 283 284 /** {@inheritDoc} */ 285 @Override protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { 286 reconfigurePopup(); 287 return super.computeMaxWidth(height, topInset, rightInset, bottomInset, leftInset); 288 } 289 290 /** {@inheritDoc} */ 291 @Override protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { 292 reconfigurePopup(); 293 return super.computeMinHeight(width, topInset, rightInset, bottomInset, leftInset); 294 } 295 296 /** {@inheritDoc} */ 297 @Override protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { 298 reconfigurePopup(); 299 return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset); 300 } 301 302 /** {@inheritDoc} */ 303 @Override protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { 304 reconfigurePopup(); 305 return super.computeMaxHeight(width, topInset, rightInset, bottomInset, leftInset); 306 } 307 308 /** {@inheritDoc} */ 309 @Override protected void layoutChildren(final double x, final double y, 310 final double w, final double h) { 311 if (listViewSelectionDirty) { 312 try { 313 listSelectionLock = true; 314 T item = comboBox.getSelectionModel().getSelectedItem(); 315 listView.getSelectionModel().clearSelection(); 316 listView.getSelectionModel().select(item); 317 } finally { 318 listSelectionLock = false; 319 listViewSelectionDirty = false; 320 } 321 } 322 323 super.layoutChildren(x, y, w, h); 324 } 325 326 327 328 /*************************************************************************** 329 * * 330 * Private methods * 331 * * 332 **************************************************************************/ 333 334 /** {@inheritDoc} */ 335 @Override void updateDisplayNode() { 336 if (getEditor() != null) { 337 super.updateDisplayNode(); 338 } else { 339 T value = comboBox.getValue(); 340 int index = getIndexOfComboBoxValueInItemsList(); 341 if (index > -1) { 342 buttonCell.setItem(null); 343 buttonCell.updateIndex(index); 344 } else { 345 // RT-21336 Show the ComboBox value even though it doesn't 346 // exist in the ComboBox items list (part two of fix) 347 buttonCell.updateIndex(-1); 348 boolean empty = updateDisplayText(buttonCell, value, false); 349 350 // Note that empty boolean collected above. This is used to resolve 351 // RT-27834, where we were getting different styling based on whether 352 // the cell was updated via the updateIndex method above, or just 353 // by directly updating the text. We fake the pseudoclass state 354 // for empty, filled, and selected here. 355 buttonCell.pseudoClassStateChanged(PSEUDO_CLASS_EMPTY, empty); 356 buttonCell.pseudoClassStateChanged(PSEUDO_CLASS_FILLED, !empty); 357 buttonCell.pseudoClassStateChanged(PSEUDO_CLASS_SELECTED, true); 358 } 359 } 360 } 361 362 /** {@inheritDoc} */ 363 @Override ComboBoxBaseBehavior getBehavior() { 364 return behavior; 365 } 366 367 private void updateComboBoxItems() { 368 comboBoxItems = comboBox.getItems(); 369 comboBoxItems = comboBoxItems == null ? FXCollections.<T>emptyObservableList() : comboBoxItems; 370 } 371 372 private void updateListViewItems() { 373 if (listViewItems != null) { 374 listViewItems.removeListener(weakListViewItemsListener); 375 } 376 377 this.listViewItems = comboBoxItems; 378 listView.setItems(listViewItems); 379 380 if (listViewItems != null) { 381 listViewItems.addListener(weakListViewItemsListener); 382 } 383 384 itemCountDirty = true; 385 getSkinnable().requestLayout(); 386 } 387 388 private void updateValue() { 389 T newValue = comboBox.getValue(); 390 391 SelectionModel<T> listViewSM = listView.getSelectionModel(); 392 393 // RT-22386: We need to test to see if the value is in the comboBox 394 // items list. If it isn't, then we should clear the listview 395 // selection 396 final int indexOfNewValue = getIndexOfComboBoxValueInItemsList(); 397 398 if (newValue == null && indexOfNewValue == -1) { 399 listViewSM.clearSelection(); 400 } else { 401 if (indexOfNewValue == -1) { 402 listSelectionLock = true; 403 listViewSM.clearSelection(); 404 listSelectionLock = false; 405 } else { 406 int index = comboBox.getSelectionModel().getSelectedIndex(); 407 if (index >= 0 && index < comboBoxItems.size()) { 408 T itemsObj = comboBoxItems.get(index); 409 if ((itemsObj != null && itemsObj.equals(newValue)) || (itemsObj == null && newValue == null)) { 410 listViewSM.select(index); 411 } else { 412 listViewSM.select(newValue); 413 } 414 } else { 415 // just select the first instance of newValue in the list 416 int listViewIndex = comboBoxItems.indexOf(newValue); 417 if (listViewIndex == -1) { 418 // RT-21336 Show the ComboBox value even though it doesn't 419 // exist in the ComboBox items list (part one of fix) 420 updateDisplayNode(); 421 } else { 422 listViewSM.select(listViewIndex); 423 } 424 } 425 } 426 } 427 } 428 429 // return a boolean to indicate that the cell is empty (and therefore not filled) 430 private boolean updateDisplayText(ListCell<T> cell, T item, boolean empty) { 431 if (empty) { 432 if (cell == null) return true; 433 cell.setGraphic(null); 434 cell.setText(null); 435 return true; 436 } else if (item instanceof Node) { 437 Node currentNode = cell.getGraphic(); 438 Node newNode = (Node) item; 439 if (currentNode == null || ! currentNode.equals(newNode)) { 440 cell.setText(null); 441 cell.setGraphic(newNode); 442 } 443 return newNode == null; 444 } else { 445 // run item through StringConverter if it isn't null 446 final StringConverter<T> c = comboBox.getConverter(); 447 final String promptText = comboBox.getPromptText(); 448 String s = item == null && promptText != null ? promptText : 449 c == null ? (item == null ? null : item.toString()) : c.toString(item); 450 cell.setText(s); 451 cell.setGraphic(null); 452 return s == null || s.isEmpty(); 453 } 454 } 455 456 private int getIndexOfComboBoxValueInItemsList() { 457 T value = comboBox.getValue(); 458 int index = comboBoxItems.indexOf(value); 459 return index; 460 } 461 462 private void updateButtonCell() { 463 buttonCell = comboBox.getButtonCell() != null ? 464 comboBox.getButtonCell() : getDefaultCellFactory().call(listView); 465 buttonCell.setMouseTransparent(true); 466 buttonCell.updateListView(listView); 467 updateDisplayArea(); 468 // As long as the screen-reader is concerned this node is not a list item. 469 // This matters because the screen-reader counts the number of list item 470 // within combo and speaks it to the user. 471 buttonCell.setAccessibleRole(AccessibleRole.NODE); 472 } 473 474 private void updateCellFactory() { 475 Callback<ListView<T>, ListCell<T>> cf = comboBox.getCellFactory(); 476 cellFactory = cf != null ? cf : getDefaultCellFactory(); 477 listView.setCellFactory(cellFactory); 478 } 479 480 private Callback<ListView<T>, ListCell<T>> getDefaultCellFactory() { 481 return new Callback<ListView<T>, ListCell<T>>() { 482 @Override public ListCell<T> call(ListView<T> listView) { 483 return new ListCell<T>() { 484 @Override public void updateItem(T item, boolean empty) { 485 super.updateItem(item, empty); 486 updateDisplayText(this, item, empty); 487 } 488 }; 489 } 490 }; 491 } 492 493 private ListView<T> createListView() { 494 final ListView<T> _listView = new ListView<T>() { 495 496 { 497 getProperties().put("selectFirstRowByDefault", false); 498 } 499 500 @Override protected double computeMinHeight(double width) { 501 return 30; 502 } 503 504 @Override protected double computePrefWidth(double height) { 505 double pw; 506 if (getSkin() instanceof ListViewSkin) { 507 ListViewSkin<?> skin = (ListViewSkin<?>)getSkin(); 508 if (itemCountDirty) { 509 skin.updateItemCount(); 510 itemCountDirty = false; 511 } 512 513 int rowsToMeasure = -1; 514 if (comboBox.getProperties().containsKey(COMBO_BOX_ROWS_TO_MEASURE_WIDTH_KEY)) { 515 rowsToMeasure = (Integer) comboBox.getProperties().get(COMBO_BOX_ROWS_TO_MEASURE_WIDTH_KEY); 516 } 517 518 pw = Math.max(comboBox.getWidth(), skin.getMaxCellWidth(rowsToMeasure) + 30); 519 } else { 520 pw = Math.max(100, comboBox.getWidth()); 521 } 522 523 // need to check the ListView pref height in the case that the 524 // placeholder node is showing 525 if (getItems().isEmpty() && getPlaceholder() != null) { 526 pw = Math.max(super.computePrefWidth(height), pw); 527 } 528 529 return Math.max(50, pw); 530 } 531 532 @Override protected double computePrefHeight(double width) { 533 return getListViewPrefHeight(); 534 } 535 }; 536 537 _listView.setId("list-view"); 538 _listView.placeholderProperty().bind(comboBox.placeholderProperty()); 539 _listView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); 540 _listView.setFocusTraversable(false); 541 542 _listView.getSelectionModel().selectedIndexProperty().addListener(o -> { 543 if (listSelectionLock) return; 544 int index = listView.getSelectionModel().getSelectedIndex(); 545 comboBox.getSelectionModel().select(index); 546 updateDisplayNode(); 547 comboBox.notifyAccessibleAttributeChanged(AccessibleAttribute.TEXT); 548 }); 549 550 comboBox.getSelectionModel().selectedItemProperty().addListener(o -> { 551 listViewSelectionDirty = true; 552 }); 553 554 _listView.addEventFilter(MouseEvent.MOUSE_RELEASED, t -> { 555 // RT-18672: Without checking if the user is clicking in the 556 // scrollbar area of the ListView, the comboBox will hide. Therefore, 557 // we add the check below to prevent this from happening. 558 EventTarget target = t.getTarget(); 559 if (target instanceof Parent) { 560 List<String> s = ((Parent) target).getStyleClass(); 561 if (s.contains("thumb") 562 || s.contains("track") 563 || s.contains("decrement-arrow") 564 || s.contains("increment-arrow")) { 565 return; 566 } 567 } 568 569 if (isHideOnClick()) { 570 comboBox.hide(); 571 } 572 }); 573 574 _listView.setOnKeyPressed(t -> { 575 // TODO move to behavior, when (or if) this class becomes a SkinBase 576 if (t.getCode() == KeyCode.ENTER || 577 t.getCode() == KeyCode.SPACE || 578 t.getCode() == KeyCode.ESCAPE) { 579 comboBox.hide(); 580 } 581 }); 582 583 return _listView; 584 } 585 586 private double getListViewPrefHeight() { 587 double ph; 588 if (listView.getSkin() instanceof VirtualContainerBase) { 589 int maxRows = comboBox.getVisibleRowCount(); 590 VirtualContainerBase<?,?> skin = (VirtualContainerBase<?,?>)listView.getSkin(); 591 ph = skin.getVirtualFlowPreferredHeight(maxRows); 592 } else { 593 double ch = comboBoxItems.size() * 25; 594 ph = Math.min(ch, 200); 595 } 596 597 return ph; 598 } 599 600 601 602 /************************************************************************** 603 * 604 * API for testing 605 * 606 *************************************************************************/ 607 608 ListView<T> getListView() { 609 return listView; 610 } 611 612 613 614 615 /*************************************************************************** 616 * * 617 * Stylesheet Handling * 618 * * 619 **************************************************************************/ 620 621 // These three pseudo class states are duplicated from Cell 622 private static final PseudoClass PSEUDO_CLASS_SELECTED = 623 PseudoClass.getPseudoClass("selected"); 624 private static final PseudoClass PSEUDO_CLASS_EMPTY = 625 PseudoClass.getPseudoClass("empty"); 626 private static final PseudoClass PSEUDO_CLASS_FILLED = 627 PseudoClass.getPseudoClass("filled"); 628 629 630 /** {@inheritDoc} */ 631 @Override public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { 632 switch (attribute) { 633 case FOCUS_ITEM: { 634 if (comboBox.isShowing()) { 635 /* On Mac, for some reason, changing the selection on the list is not 636 * reported by VoiceOver the first time it shows. 637 * Note that this fix returns a child of the PopupWindow back to the main 638 * Stage, which doesn't seem to cause problems. 639 */ 640 return listView.queryAccessibleAttribute(attribute, parameters); 641 } 642 return null; 643 } 644 case TEXT: { 645 String accText = comboBox.getAccessibleText(); 646 if (accText != null && !accText.isEmpty()) return accText; 647 String title = comboBox.isEditable() ? getEditor().getText() : buttonCell.getText(); 648 if (title == null || title.isEmpty()) { 649 title = comboBox.getPromptText(); 650 } 651 return title; 652 } 653 case SELECTION_START: return getEditor().getSelection().getStart(); 654 case SELECTION_END: return getEditor().getSelection().getEnd(); 655 default: return super.queryAccessibleAttribute(attribute, parameters); 656 } 657 } 658 } 659