/* * Copyright (c) 2010, 2016, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. Oracle designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. * * This code is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ package javafx.scene.control; import com.sun.javafx.scene.control.FakeFocusTextField; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.WeakInvalidationListener; import javafx.collections.WeakListChangeListener; import javafx.scene.control.skin.ComboBoxListViewSkin; import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.scene.AccessibleAttribute; import javafx.scene.AccessibleRole; import javafx.scene.Node; import javafx.util.Callback; import javafx.util.StringConverter; import java.lang.ref.WeakReference; /** * An implementation of the {@link ComboBoxBase} abstract class for the most common * form of ComboBox, where a popup list is shown to users providing them with * a choice that they may select from. For more information around the general * concepts and API of ComboBox, refer to the {@link ComboBoxBase} class * documentation. * *

On top of ComboBoxBase, the ComboBox class introduces additional API. Most * importantly, it adds an {@link #itemsProperty() items} property that works in * much the same way as the ListView {@link ListView#itemsProperty() items} * property. In other words, it is the content of the items list that is displayed * to users when they click on the ComboBox button. * *

The ComboBox exposes the {@link #valueProperty()} from * {@link javafx.scene.control.ComboBoxBase}, but there are some important points * of the value property that need to be understood in relation to ComboBox. * These include: * *

    *
  1. The value property is not constrained to items contained * within the items list - it can be anything as long as it is a valid value * of type T.
  2. *
  3. If the value property is set to a non-null object, and subsequently the * items list is cleared, the value property is not nulled out.
  4. *
  5. Clearing the {@link javafx.scene.control.SelectionModel#clearSelection() * selection} in the selection model does not null the value * property - it remains the same as before.
  6. *
  7. It is valid for the selection model to have a selection set to a given * index even if there is no items in the list (or less items in the list than * the given index). Once the items list is further populated, such that the * list contains enough items to have an item in the given index, both the * selection model {@link SelectionModel#selectedItemProperty()} and * value property will be updated to have this value. This is inconsistent with * other controls that use a selection model, but done intentionally for ComboBox.
  8. *
* *

By default, when the popup list is showing, the maximum number of rows * visible is 10, but this can be changed by modifying the * {@link #visibleRowCountProperty() visibleRowCount} property. If the number of * items in the ComboBox is less than the value of visibleRowCount, * then the items size will be used instead so that the popup list is not * exceedingly long. * *

As with ListView, it is possible to modify the * {@link javafx.scene.control.SelectionModel selection model} that is used, * although this is likely to be rarely changed. This is because the ComboBox * enforces the need for a {@link javafx.scene.control.SingleSelectionModel} * instance, and it is not likely that there is much need for alternate * implementations. Nonetheless, the option is there should use cases be found * for switching the selection model. * *

As the ComboBox internally renders content with a ListView, API exists in * the ComboBox class to allow for a custom cell factory to be set. For more * information on cell factories, refer to the {@link Cell} and {@link ListCell} * classes. It is important to note that if a cell factory is set on a ComboBox, * cells will only be used in the ListView that shows when the ComboBox is * clicked. If you also want to customize the rendering of the 'button' area * of the ComboBox, you can set a custom {@link ListCell} instance in the * {@link #buttonCellProperty() button cell} property. One way of doing this * is with the following code (note the use of {@code setButtonCell}: * *

 * {@code
 * Callback, ListCell> cellFactory = ...;
 * ComboBox comboBox = new ComboBox();
 * comboBox.setItems(items);
 * comboBox.setButtonCell(cellFactory.call(null));
 * comboBox.setCellFactory(cellFactory);}
* *

Because a ComboBox can be {@link #editableProperty() editable}, and the * default means of allowing user input is via a {@link TextField}, a * {@link #converterProperty() string converter} property is provided to allow * for developers to specify how to translate a users string into an object of * type T, such that the {@link #valueProperty() value} property may contain it. * By default the converter simply returns the String input as the user typed it, * which therefore assumes that the type of the editable ComboBox is String. If * a different type is specified and the ComboBox is to be editable, it is * necessary to specify a custom {@link StringConverter}. * *

A warning about inserting Nodes into the ComboBox items list

* ComboBox allows for the items list to contain elements of any type, including * {@link Node} instances. Putting nodes into * the items list is strongly not recommended. This is because * the default {@link #cellFactoryProperty() cell factory} simply inserts Node * items directly into the cell, including in the ComboBox 'button' area too. * Because the scenegraph only allows for Nodes to be in one place at a time, * this means that when an item is selected it becomes removed from the ComboBox * list, and becomes visible in the button area. When selection changes the * previously selected item returns to the list and the new selection is removed. * *

The recommended approach, rather than inserting Node instances into the * items list, is to put the relevant information into the ComboBox, and then * provide a custom {@link #cellFactoryProperty() cell factory}. For example, * rather than use the following code: * *

 * {@code
 * ComboBox cmb = new ComboBox();
 * cmb.getItems().addAll(
 *     new Rectangle(10, 10, Color.RED),
 *     new Rectangle(10, 10, Color.GREEN),
 *     new Rectangle(10, 10, Color.BLUE));}
* *

You should do the following:

* *

 * ComboBox<Color> cmb = new ComboBox<Color>();
 * cmb.getItems().addAll(
 *     Color.RED,
 *     Color.GREEN,
 *     Color.BLUE);
 *
 * cmb.setCellFactory(new Callback<ListView<Color>, ListCell<Color>>() {
 *     @Override public ListCell<Color> call(ListView<Color> p) {
 *         return new ListCell<Color>() {
 *             private final Rectangle rectangle;
 *             {
 *                 setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
 *                 rectangle = new Rectangle(10, 10);
 *             }
 *
 *             @Override protected void updateItem(Color item, boolean empty) {
 *                 super.updateItem(item, empty);
 *
 *                 if (item == null || empty) {
 *                     setGraphic(null);
 *                 } else {
 *                     rectangle.setFill(item);
 *                     setGraphic(rectangle);
 *                 }
 *            }
 *       };
 *   }
 *});
* *

Admittedly the above approach is far more verbose, but it offers the * required functionality without encountering the scenegraph constraints. * * @param The type of the value that has been selected or otherwise entered * in to this ComboBox * @see ComboBoxBase * @see Cell * @see ListCell * @see StringConverter * @since JavaFX 2.1 */ public class ComboBox extends ComboBoxBase { /*************************************************************************** * * * Static properties and methods * * * **************************************************************************/ private static StringConverter defaultStringConverter() { return new StringConverter() { @Override public String toString(T t) { return t == null ? null : t.toString(); } @Override public T fromString(String string) { return (T) string; } }; } /*************************************************************************** * * * Constructors * * * **************************************************************************/ /** * Creates a default ComboBox instance with an empty * {@link #itemsProperty() items} list and default * {@link #selectionModelProperty() selection model}. */ public ComboBox() { this(FXCollections.observableArrayList()); } /** * Creates a default ComboBox instance with the provided items list and * a default {@link #selectionModelProperty() selection model}. * @param items the list of items */ public ComboBox(ObservableList items) { getStyleClass().add(DEFAULT_STYLE_CLASS); setAccessibleRole(AccessibleRole.COMBO_BOX); setItems(items); setSelectionModel(new ComboBoxSelectionModel(this)); // listen to the value property input by the user, and if the value is // set to something that exists in the items list, we should update the // selection model to indicate that this is the selected item valueProperty().addListener((ov, t, t1) -> { if (getItems() == null) return; SelectionModel sm = getSelectionModel(); int index = getItems().indexOf(t1); if (index == -1) { Runnable r = () -> { sm.setSelectedIndex(-1); sm.setSelectedItem(t1); }; if (sm instanceof ComboBoxSelectionModel) { ((ComboBoxSelectionModel)sm).doAtomic(r); } else { r.run(); } } else { // we must compare the value here with the currently selected // item. If they are different, we overwrite the selection // properties to reflect the new value. // We do this as there can be circumstances where there are // multiple instances of a value in the ComboBox items list, // and if we don't check here we may change the selection // mistakenly because the indexOf above will return the first // instance always, and selection may be on the second or // later instances. This is RT-19227. T selectedItem = sm.getSelectedItem(); if (selectedItem == null || ! selectedItem.equals(getValue())) { sm.clearAndSelect(index); } } }); editableProperty().addListener(o -> { // When we change from being editable to non-editable, we look for the // current value in the items list. If it exists, we do not clear selection. // When we change from being non-editable to editable, we do nothing if (!isEditable()) { // check if value is in items list if (getItems() != null && !getItems().contains(getValue())) { getSelectionModel().clearSelection(); } } }); focusedProperty().addListener(o -> { if (!isFocused()) { commitValue(); } }); } /*************************************************************************** * * * Properties * * * **************************************************************************/ // --- items /** * The list of items to show within the ComboBox popup. */ private ObjectProperty> items = new SimpleObjectProperty>(this, "items"); public final void setItems(ObservableList value) { itemsProperty().set(value); } public final ObservableList getItems() {return items.get(); } public ObjectProperty> itemsProperty() { return items; } // --- string converter /** * Converts the user-typed input (when the ComboBox is * {@link #editableProperty() editable}) to an object of type T, such that * the input may be retrieved via the {@link #valueProperty() value} property. * @return the converter property */ public ObjectProperty> converterProperty() { return converter; } private ObjectProperty> converter = new SimpleObjectProperty>(this, "converter", ComboBox.defaultStringConverter()); public final void setConverter(StringConverter value) { converterProperty().set(value); } public final StringConverter getConverter() {return converterProperty().get(); } // --- cell factory /** * Providing a custom cell factory allows for complete customization of the * rendering of items in the ComboBox. Refer to the {@link Cell} javadoc * for more information on cell factories. */ private ObjectProperty, ListCell>> cellFactory = new SimpleObjectProperty, ListCell>>(this, "cellFactory"); public final void setCellFactory(Callback, ListCell> value) { cellFactoryProperty().set(value); } public final Callback, ListCell> getCellFactory() {return cellFactoryProperty().get(); } public ObjectProperty, ListCell>> cellFactoryProperty() { return cellFactory; } // --- button cell /** * The button cell is used to render what is shown in the ComboBox 'button' * area. If a cell is set here, it does not change the rendering of the * ComboBox popup list - that rendering is controlled via the * {@link #cellFactoryProperty() cell factory} API. * @return the button cell property * @since JavaFX 2.2 */ public ObjectProperty> buttonCellProperty() { return buttonCell; } private ObjectProperty> buttonCell = new SimpleObjectProperty>(this, "buttonCell"); public final void setButtonCell(ListCell value) { buttonCellProperty().set(value); } public final ListCell getButtonCell() {return buttonCellProperty().get(); } // --- Selection Model /** * The selection model for the ComboBox. A ComboBox only supports * single selection. */ private ObjectProperty> selectionModel = new SimpleObjectProperty>(this, "selectionModel") { private SingleSelectionModel oldSM = null; @Override protected void invalidated() { if (oldSM != null) { oldSM.selectedItemProperty().removeListener(selectedItemListener); } SingleSelectionModel sm = get(); oldSM = sm; if (sm != null) { sm.selectedItemProperty().addListener(selectedItemListener); } } }; public final void setSelectionModel(SingleSelectionModel value) { selectionModel.set(value); } public final SingleSelectionModel getSelectionModel() { return selectionModel.get(); } public final ObjectProperty> selectionModelProperty() { return selectionModel; } // --- Visible Row Count /** * The maximum number of rows to be visible in the ComboBox popup when it is * showing. By default this value is 10, but this can be changed to increase * or decrease the height of the popup. */ private IntegerProperty visibleRowCount = new SimpleIntegerProperty(this, "visibleRowCount", 10); public final void setVisibleRowCount(int value) { visibleRowCount.set(value); } public final int getVisibleRowCount() { return visibleRowCount.get(); } public final IntegerProperty visibleRowCountProperty() { return visibleRowCount; } // --- Editor private TextField textField; /** * The editor for the ComboBox. The editor is null if the ComboBox is not * {@link #editableProperty() editable}. * @since JavaFX 2.2 */ private ReadOnlyObjectWrapper editor; public final TextField getEditor() { return editorProperty().get(); } public final ReadOnlyObjectProperty editorProperty() { if (editor == null) { editor = new ReadOnlyObjectWrapper<>(this, "editor"); textField = new FakeFocusTextField(); editor.set(textField); } return editor.getReadOnlyProperty(); } // --- Placeholder Node private ObjectProperty placeholder; /** * This Node is shown to the user when the ComboBox has no content to show. * The placeholder node is shown in the ComboBox popup area * when the items list is null or empty. * @return the placeholder property * @since JavaFX 8.0 */ public final ObjectProperty placeholderProperty() { if (placeholder == null) { placeholder = new SimpleObjectProperty(this, "placeholder"); } return placeholder; } public final void setPlaceholder(Node value) { placeholderProperty().set(value); } public final Node getPlaceholder() { return placeholder == null ? null : placeholder.get(); } /*************************************************************************** * * * Methods * * * **************************************************************************/ /** {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new ComboBoxListViewSkin(this); } /** * If the ComboBox is {@link #editableProperty() editable}, calling this method will attempt to * commit the current text and convert it to a {@link #valueProperty() value}. * @since 9 */ public final void commitValue() { if (!isEditable()) return; String text = getEditor().getText(); StringConverter converter = getConverter(); if (converter != null) { T value = converter.fromString(text); setValue(value); } } /** * If the ComboBox is {@link #editableProperty() editable}, calling this method will attempt to * replace the editor text with the last committed {@link #valueProperty() value}. * @since 9 */ public final void cancelEdit() { if (!isEditable()) return; final T committedValue = getValue(); StringConverter converter = getConverter(); if (converter != null) { String valueString = converter.toString(committedValue); getEditor().setText(valueString); } } /*************************************************************************** * * * Callbacks and Events * * * **************************************************************************/ // Listen to changes in the selectedItem property of the SelectionModel. // When it changes, set the selectedItem in the value property. private ChangeListener selectedItemListener = new ChangeListener() { @Override public void changed(ObservableValue ov, T t, T t1) { if (wasSetAllCalled && t1 == null) { // no-op: fix for RT-22572 where the developer was completely // replacing all items in the ComboBox, and expecting the // selection (and ComboBox.value) to remain set. If this isn't // here, we would updateValue(null). // Additional fix for RT-22937: adding the '&& t1 == null'. // Without this, there would be circumstances where the user // selecting a new value from the ComboBox would end up in here, // when we really should go into the updateValue(t1) call below. // We should only ever go into this clause if t1 is null. } else { updateValue(t1); } wasSetAllCalled = false; } }; /*************************************************************************** * * * Private methods * * * **************************************************************************/ private void updateValue(T newValue) { if (! valueProperty().isBound()) { setValue(newValue); } } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ private static final String DEFAULT_STYLE_CLASS = "combo-box"; private boolean wasSetAllCalled = false; private int previousItemCount = -1; // package for testing static class ComboBoxSelectionModel extends SingleSelectionModel { private final ComboBox comboBox; private boolean atomic = false; private void doAtomic(Runnable r) { atomic = true; r.run(); atomic = false; } public ComboBoxSelectionModel(final ComboBox cb) { if (cb == null) { throw new NullPointerException("ComboBox can not be null"); } this.comboBox = cb; this.comboBox.previousItemCount = getItemCount(); selectedIndexProperty().addListener(valueModel -> { // we used to lazily retrieve the selected item, but now we just // do it when the selection changes. if (atomic) return; setSelectedItem(getModelItem(getSelectedIndex())); }); /* * The following two listeners are used in conjunction with * SelectionModel.select(T obj) to allow for a developer to select * an item that is not actually in the data model. When this occurs, * we actively try to find an index that matches this object, going * so far as to actually watch for all changes to the items list, * rechecking each time. */ itemsObserver = new InvalidationListener() { private WeakReference> weakItemsRef = new WeakReference<>(comboBox.getItems()); @Override public void invalidated(Observable observable) { ObservableList oldItems = weakItemsRef.get(); weakItemsRef = new WeakReference<>(comboBox.getItems()); updateItemsObserver(oldItems, comboBox.getItems()); comboBox.previousItemCount = getItemCount(); } }; this.comboBox.itemsProperty().addListener(new WeakInvalidationListener(itemsObserver)); if (comboBox.getItems() != null) { this.comboBox.getItems().addListener(weakItemsContentObserver); } } // watching for changes to the items list content private final ListChangeListener itemsContentObserver = new ListChangeListener() { @Override public void onChanged(Change c) { if (comboBox.getItems() == null || comboBox.getItems().isEmpty()) { setSelectedIndex(-1); } else if (getSelectedIndex() == -1 && getSelectedItem() != null) { int newIndex = comboBox.getItems().indexOf(getSelectedItem()); if (newIndex != -1) { setSelectedIndex(newIndex); } } int shift = 0; while (c.next()) { comboBox.wasSetAllCalled = comboBox.previousItemCount == c.getRemovedSize(); if (c.wasReplaced()) { // no-op } else if (c.wasAdded() || c.wasRemoved()) { if (c.getFrom() <= getSelectedIndex() && getSelectedIndex()!= -1) { shift += c.wasAdded() ? c.getAddedSize() : -c.getRemovedSize(); } } } if (shift != 0) { clearAndSelect(getSelectedIndex() + shift); } else if (comboBox.wasSetAllCalled && getSelectedIndex() >= 0 && getSelectedItem() != null) { // try to find the previously selected item T selectedItem = getSelectedItem(); for (int i = 0; i < comboBox.getItems().size(); i++) { if (selectedItem.equals(comboBox.getItems().get(i))) { comboBox.setValue(null); setSelectedItem(null); setSelectedIndex(i); break; } } } comboBox.previousItemCount = getItemCount(); } }; // watching for changes to the items list private final InvalidationListener itemsObserver; private WeakListChangeListener weakItemsContentObserver = new WeakListChangeListener(itemsContentObserver); private void updateItemsObserver(ObservableList oldList, ObservableList newList) { // update listeners if (oldList != null) { oldList.removeListener(weakItemsContentObserver); } if (newList != null) { newList.addListener(weakItemsContentObserver); } // when the items list totally changes, we should clear out // the selection and focus int newValueIndex = -1; if (newList != null) { T value = comboBox.getValue(); if (value != null) { newValueIndex = newList.indexOf(value); } } setSelectedIndex(newValueIndex); } // API Implementation @Override protected T getModelItem(int index) { final ObservableList items = comboBox.getItems(); if (items == null) return null; if (index < 0 || index >= items.size()) return null; return items.get(index); } @Override protected int getItemCount() { final ObservableList items = comboBox.getItems(); return items == null ? 0 : items.size(); } } /*************************************************************************** * * * Accessibility handling * * * **************************************************************************/ @Override public Object queryAccessibleAttribute(AccessibleAttribute attribute, Object... parameters) { switch(attribute) { case TEXT: String accText = getAccessibleText(); if (accText != null && !accText.isEmpty()) return accText; //let the skin first. Object title = super.queryAccessibleAttribute(attribute, parameters); if (title != null) return title; StringConverter converter = getConverter(); if (converter == null) { return getValue() != null ? getValue().toString() : ""; } return converter.toString(getValue()); default: return super.queryAccessibleAttribute(attribute, parameters); } } }