Java Swing 自定义组件库分享(十)

0 阅读6分钟

可搜索下拉框 — CusComboBox

一、背景

Swing 原生的 JComboBox 虽然支持下拉选择,但有两个明显的痛点:

  1. 输入框只能从下拉列表中选择,不支持输入筛选
  2. 当数据量较大时,用户需要滚动查找,体验较差

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);

六、注意事项

  1. 必须调用 setEditable(true) :只有设置为可编辑模式,搜索功能才会生效
  2. 中文输入法:代码已通过 InputMethodListener 处理,输入中文时不会触发搜索
  3. 数据源引用:过滤时会重新构建下拉列表,原始 dataList 会被保留作为数据源
  4. null项保护:当 keepNull = true 时,removeAllItems() 和 removeItemAt(0) 会被保护
  5. ActionEvent 抑制:搜索过程中会临时抑制 ActionEvent,避免重复触发

七、小结

CusComboBox 在原生 JComboBox 基础上增加了可搜索功能,核心实现要点:

  • setEditable(true) 启用可编辑模式
  • 自定义 BasicComboBoxEditor 替代默认编辑器
  • 监听输入框文本变化,动态过滤下拉选项
  • 延迟搜索 + 中文输入法处理
  • 键盘导航(上下键、回车、ESC)