javafx.scene.control.ComboBox Maven / Gradle / Ivy
/*
* Copyright (c) 2010, 2024, 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:
*
*
* - 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.
* - 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.
* - 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.
* - 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.
*
*
* 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}.
*
*
Warning: Nodes should not be inserted directly into the ComboBox items list
* {@code ComboBox} allows for the items list to contain elements of any type, including
* {@link Node} instances. Putting nodes into
* the items list is strongly discouraged, as it can
* lead to unexpected results. 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.
*
*Important points to note:
*
* - Avoid inserting {@code Node} instances directly into the {@code ComboBox} items list or its data model.
* - The recommended approach is to put the relevant information into the items list, and
* provide a custom {@link #cellFactoryProperty() cell factory} to create the nodes for a
* given cell and update them on demand using the data stored in the item for that cell.
* - Avoid creating new {@code Node}s in the {@code updateItem} method of
* a custom {@link #cellFactoryProperty() cell factory}.
*
* The following minimal example shows how to create a custom cell factory for {@code ComboBox} containing {@code Node}s:
*
*
ComboBox<Color> cmb = new ComboBox<>();
* cmb.getItems().addAll(
* Color.RED,
* Color.GREEN,
* Color.BLUE);
*
* cmb.setCellFactory(p {@literal ->} {
* return new ListCell<>() {
* 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);
* }
* }
* };
* });
* This example has an anonymous custom {@code ListCell} class in the custom cell factory.
* Note that the {@code Rectangle} ({@code Node}) object needs to be created in the instance initialization block
* or the constructor of the custom {@code ListCell} class and updated/used in its {@code updateItem} method.
*
*
*
*
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();
if (sm == null) return;
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())) {
SingleSelectionModel selectionModel = getSelectionModel();
if (selectionModel != null) {
selectionModel.clearSelection();
}
}
}
});
focusedProperty().addListener(o -> {
if (!isFocused()) {
commitValue();
}
});
}
/* *************************************************************************
* *
* Properties *
* *
**************************************************************************/
// --- items
/**
* The list of items to show within the ComboBox popup.
*/
private final ObjectProperty> items = new SimpleObjectProperty<>(this, "items");
public final void setItems(ObservableList value) { itemsProperty().set(value); }
public final ObservableList getItems() {return items.get(); }
public final 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 final ObjectProperty> converterProperty() { return converter; }
private final 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 final ObjectProperty, ListCell>> cellFactory =
new SimpleObjectProperty<>(this, "cellFactory");
public final void setCellFactory(Callback, ListCell> value) { cellFactoryProperty().set(value); }
public final Callback, ListCell> getCellFactory() {return cellFactoryProperty().get(); }
public final 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 final ObjectProperty> buttonCellProperty() { return buttonCell; }
private final 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 extends T> 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 extends T> 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))) {
clearAndSelect(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 *
* *
**************************************************************************/
/** {@inheritDoc} */
@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);
}
}
}