可搜索下拉框 — CusComboBox
一、背景
Swing 原生的 JComboBox 虽然支持下拉选择,但有两个明显的痛点:
- 输入框只能从下拉列表中选择,不支持输入筛选
- 当数据量较大时,用户需要滚动查找,体验较差
CusComboBox 的作用就是:在原生 JComboBox 基础上增加可搜索功能。用户输入关键字时自动过滤下拉选项,支持键盘导航(上下键选择、回车确认、ESC取消),并提供无匹配结果时的回调处理。
二、核心设计
CusComboBox 继承 JComboBox,通过 setEditable(true) 启用可编辑模式,然后:
- 自定义编辑器(
BasicComboBoxEditor),用JTextField作为输入框 - 监听输入框文本变化,动态过滤下拉列表
- 支持键盘导航(上下键、回车、ESC)
- 支持中文输入法(处理输入法组合状态)
- 延迟搜索(200ms 防抖),避免频繁过滤
三、类源码
import cn.hutool.core.collection.CollectionUtil;
import javax.swing.*;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import javax.swing.plaf.basic.BasicComboBoxEditor;
import java.awt.*;
import java.awt.event.*;
import java.text.AttributedCharacterIterator;
import java.util.Collection;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* 自定义下拉选择器(可搜索)
* 支持输入关键字过滤下拉选项、键盘导航、无匹配回调
*
* 使用示例:
* 1. 基本类型数据:
* List<String> list = Arrays.asList("苹果", "香蕉", "橙子");
* CusComboBox<String> comboBox = new CusComboBox<>(list);
*
* 2. 对象数据,指定显示字段:
* CusComboBox<User> comboBox = new CusComboBox<>(userList, User::getName);
*
* 3. 设置无匹配回调:
* comboBox.setOnNoMatch(text -> System.out.println("无匹配:" + text));
*/
public class CusComboBox<T> extends JComboBox<T> {
/** 显示的字段 */
private Function<T, String> field;
/** null项的显示文本 */
private String nullText;
/** 是否保留null项 */
private boolean keepNull = false;
/** 数据集 */
private Collection<? extends T> dataList;
/** 是否正在输入(中文输入法组合状态) */
private static boolean isComposing = false;
/** 是否正在搜索,用于抑制输入筛选前的选中事件 */
private boolean suppressActionEvents = false;
/** 键盘导航标志 */
private boolean keyboardNavigating = false;
/** 待确认的选择项 */
private T pendingSelection = null;
/** 输入无匹配项时的回调 */
private Consumer<String> onNoMatchCallback;
public CusComboBox() {
super();
}
/**
* 基本数据类型包装类和String的便利构造方法
* @param items 数据源
*/
@SuppressWarnings("unchecked")
public CusComboBox(Collection<?> items) {
this.field = Object::toString;
this.keepNull = false;
this.dataList = (Collection<T>) items;
initRenderer();
addAllItems(this.dataList);
}
/**
* 基本数据类型包装类和String的便利构造方法(包含null项)
* @param items 数据源
* @param nullText null项的显示文本
*/
@SuppressWarnings("unchecked")
public CusComboBox(Collection<?> items, String nullText) {
this.field = Object::toString;
this.nullText = nullText;
this.keepNull = true;
this.dataList = (Collection<T>) items;
initRenderer();
addAllItems(this.dataList);
}
/**
* 泛型构造方法
* @param items 数据源
* @param field 显示字段提取函数
*/
public CusComboBox(Collection<? extends T> items, Function<T, String> field) {
super();
this.field = field;
this.keepNull = false;
this.dataList = items;
initRenderer();
addAllItems(items);
}
/**
* 泛型构造方法(包含null项)
* @param items 数据源
* @param field 显示字段提取函数
* @param nullText null项的显示文本
*/
public CusComboBox(Collection<? extends T> items, Function<T, String> field, String nullText) {
super();
this.field = field;
this.nullText = nullText;
this.keepNull = true;
this.dataList = items;
initRenderer();
addAllItems(items);
}
/**
* 初始化渲染器
*/
@SuppressWarnings("unchecked")
private void initRenderer() {
setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean isSelected, boolean cellHasFocus) {
super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
setText(value == null ? nullText : field.apply((T) value));
return this;
}
});
}
/**
* 设置显示字段
* @param field 显示字段提取函数
*/
public void setDisplayField(Function<T, String> field) {
this.field = field;
this.updateUI();
}
/**
* 添加所有数据项
* @param items 数据源
*/
private void addAllItems(Collection<? extends T> items) {
if (keepNull) {
addItem(null);
}
items.forEach(this::addItem);
}
/**
* 移除所有项(保护null项)
*/
@Override
public void removeAllItems() {
if (keepNull) {
super.removeAllItems();
addItem(null);
} else {
super.removeAllItems();
}
}
/**
* 移除指定索引的项(保护null项)
* @param index 索引
*/
@Override
public void removeItemAt(int index) {
if (keepNull && index == 0) {
return;
}
super.removeItemAt(index);
}
/**
* 设置是否可搜索
* @param editable true=可搜索,false=不可搜索
*/
@Override
public void setEditable(boolean editable) {
super.setEditable(editable);
if (editable) {
initEditor();
handleTextChange();
}
}
/**
* 初始化可搜索时的编辑器
*/
private void initEditor() {
JTextField searchField = new JTextField();
JTextField originalField = (JTextField) getEditor().getEditorComponent();
searchField.setBorder(originalField.getBorder());
searchField.setBackground(originalField.getBackground());
searchField.setForeground(originalField.getForeground());
searchField.setFont(originalField.getFont());
searchField.setPreferredSize(originalField.getPreferredSize());
setEditor(new BasicComboBoxEditor() {
@Override
public void setItem(Object item) {
if (item == null) {
searchField.setText("");
} else {
@SuppressWarnings("unchecked")
T typedItem = (T) item;
String text = field.apply(typedItem);
searchField.setText(text);
searchField.setCaretPosition(text.length());
}
}
@Override
public Object getItem() {
return getSelectedItem();
}
@Override
public Component getEditorComponent() {
return searchField;
}
});
// 下拉面板宽度控制
addPopupMenuListener(new PopupMenuListener() {
@Override
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
JPopupMenu popup = (JPopupMenu) getUI().getAccessibleChild(CusComboBox.this, 0);
if (null != popup) {
int dataHeight = 60;
if (CollectionUtil.isNotEmpty(dataList)) {
int height = Math.min(dataList.size() * 25, 380);
dataHeight = Math.max(dataHeight, height);
}
popup.setPreferredSize(new Dimension(getWidth(), dataHeight));
}
}
@Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {}
@Override
public void popupMenuCanceled(PopupMenuEvent e) {}
});
setupKeyboardNavigation(searchField);
}
/**
* 设置键盘导航
* @param searchField 搜索输入框
*/
private void setupKeyboardNavigation(JTextField searchField) {
searchField.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_ENTER) {
// 回车确认选择
if (keyboardNavigating && pendingSelection != null) {
suppressActionEvents = false;
setSelectedItem(pendingSelection);
searchField.setText(field.apply(pendingSelection));
keyboardNavigating = false;
pendingSelection = null;
setPopupVisible(false);
e.consume();
}
} else if (e.getKeyCode() == KeyEvent.VK_UP || e.getKeyCode() == KeyEvent.VK_DOWN) {
// 上下键开始键盘导航
if (!keyboardNavigating) {
keyboardNavigating = true;
pendingSelection = getItemAt(0);
suppressActionEvents = true;
}
} else if (e.getKeyCode() == KeyEvent.VK_ESCAPE) {
// ESC取消导航
keyboardNavigating = false;
pendingSelection = null;
suppressActionEvents = false;
setPopupVisible(false);
e.consume();
}
}
});
addItemListener(e -> {
if (keyboardNavigating && e.getStateChange() == ItemEvent.SELECTED) {
@SuppressWarnings("unchecked")
T selected = (T) e.getItem();
pendingSelection = selected;
}
});
}
/**
* 重写fireActionEvent,在搜索时抑制事件
*/
@Override
protected void fireActionEvent() {
if (!suppressActionEvents) {
super.fireActionEvent();
}
}
/**
* 处理输入框文本变化(搜索过滤)
*/
private void handleTextChange() {
JTextField textField = (JTextField) getEditor().getEditorComponent();
// 处理中文输入法组合状态
textField.addInputMethodListener(new InputMethodListener() {
@Override
public void inputMethodTextChanged(InputMethodEvent event) {
AttributedCharacterIterator text = event.getText();
isComposing = text != null && text.getEndIndex() - text.getBeginIndex() > 0;
}
@Override
public void caretPositionChanged(InputMethodEvent event) {}
});
textField.addKeyListener(new KeyAdapter() {
private Timer searchTimer;
@Override
public void keyPressed(KeyEvent e) {
if (e.getKeyCode() == KeyEvent.VK_ENTER || e.getKeyCode() == KeyEvent.VK_SPACE) {
isComposing = false;
}
}
@Override
public void keyReleased(KeyEvent e) {
if (isComposing) return;
// 导航键不触发搜索
if (e.getKeyCode() == KeyEvent.VK_UP ||
e.getKeyCode() == KeyEvent.VK_DOWN ||
e.getKeyCode() == KeyEvent.VK_ENTER ||
e.getKeyCode() == KeyEvent.VK_ESCAPE) {
return;
}
if (null != searchTimer) {
searchTimer.stop();
}
searchTimer = new Timer(200, evt -> {
suppressActionEvents = true;
keyboardNavigating = false;
pendingSelection = null;
String text = textField.getText();
Object previouslySelectedItem = getSelectedItem();
AtomicBoolean hasMatches = new AtomicBoolean(false);
removeAllItems();
dataList.forEach(item -> {
String fieldValue = field.apply(item);
if (fieldValue.toLowerCase().contains(text.toLowerCase())) {
addItem(item);
hasMatches.set(true);
}
});
if (!hasMatches.get() && !text.isEmpty()) {
CallbackProcessor.accept(onNoMatchCallback, text);
}
setPopupVisible(hasMatches.get() && !text.isEmpty());
setSelectedItem(previouslySelectedItem);
textField.setText(text);
suppressActionEvents = false;
});
searchTimer.setRepeats(false);
searchTimer.start();
}
});
}
/**
* 获取输入框的文本
* @return 输入框文本
*/
public String getEditorText() {
if (isEditable() && null != getEditor()) {
Component editorComponent = getEditor().getEditorComponent();
if (editorComponent instanceof JTextField) {
return ((JTextField) editorComponent).getText();
}
}
return null;
}
/**
* 设置无匹配结果的回调
* @param callback 回调函数,参数为输入的关键字
*/
public void setOnNoMatch(Consumer<String> callback) {
this.onNoMatchCallback = callback;
}
/**
* 静态工厂方法
*/
public static <T> CusComboBox<T> create(Collection<?> items) {
return new CusComboBox<>(items);
}
public static <T> CusComboBox<T> create(Collection<?> items, String nullText) {
return new CusComboBox<>(items, nullText);
}
public static <T> CusComboBox<T> create(Collection<? extends T> items, Function<T, String> field) {
return new CusComboBox<>(items, field);
}
public static <T> CusComboBox<T> create(Collection<? extends T> items, Function<T, String> field, String nullText) {
return new CusComboBox<>(items, field, nullText);
}
}
四、核心功能说明
可搜索过滤:
- 输入关键字时自动过滤下拉选项(大小写不敏感)
- 延迟200ms搜索,避免频繁过滤
- 支持中文输入法(
InputMethodListener处理组合状态)
键盘导航:
↑/↓:上下移动选择,开始键盘导航模式Enter:确认当前高亮项ESC:取消导航,关闭下拉面板
数据源适配:
- 支持
Collection<String>等基本类型 - 支持任意泛型对象,通过
Function<T, String>提取显示字段 - 支持保留
null项(带占位文本)
其他特性:
- 无匹配结果时的回调处理
- 下拉面板宽度自适应
- 抑制搜索过程中的
ActionEvent事件
五、使用示例
5.1 基本类型数据
List<String> fruits = Arrays.asList("苹果", "香蕉", "橙子", "葡萄", "西瓜");
CusComboBox<String> comboBox = new CusComboBox<>(fruits);
comboBox.setEditable(true); // 开启可搜索
panel.add(comboBox);
5.2 对象数据,指定显示字段
List<User> userList = getUserList();
CusComboBox<User> comboBox = new CusComboBox<>(userList, User::getName);
comboBox.setEditable(true);
panel.add(comboBox);
5.3 包含 null 项
CusComboBox<String> comboBox = new CusComboBox<>(list, "请选择");
comboBox.setEditable(true);
5.4 设置无匹配回调
comboBox.setOnNoMatch(keyword -> {
System.out.println("未找到匹配项:" + keyword);
// 可弹窗提示或动态添加选项
});
5.5 获取选中值和输入文本
// 获取选中对象
User selected = comboBox.getSelectedItem();
// 获取输入框文本
String text = comboBox.getEditorText();
5.6 静态工厂方法
CusComboBox<String> comboBox = CusComboBox.create(list, "请选择");
comboBox.setEditable(true);
六、注意事项
- 必须调用 setEditable(true) :只有设置为可编辑模式,搜索功能才会生效
- 中文输入法:代码已通过
InputMethodListener处理,输入中文时不会触发搜索 - 数据源引用:过滤时会重新构建下拉列表,原始
dataList会被保留作为数据源 - null项保护:当
keepNull = true时,removeAllItems()和removeItemAt(0)会被保护 - ActionEvent 抑制:搜索过程中会临时抑制
ActionEvent,避免重复触发
七、小结
CusComboBox 在原生 JComboBox 基础上增加了可搜索功能,核心实现要点:
setEditable(true)启用可编辑模式- 自定义
BasicComboBoxEditor替代默认编辑器 - 监听输入框文本变化,动态过滤下拉选项
- 延迟搜索 + 中文输入法处理
- 键盘导航(上下键、回车、ESC)