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