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