ComboBox с автозавершением (AutoComplete) по подстроке

Источник: delphikingdom
Андрей Чистяков

Постановка задачи

В интернете достаточно много информации по реализации функции автозавершения вводимого в TextBox или ComboBox текста - так, как это сделано в диалоговом окне "Пуск / Выполнить". Данную функцию можно реализовать с помощью интерфейсов IAutoComplete/IAutoComplete2. Эти интерфейсы позволяют формировать список вариантов автозавершения, подбирая те строки, которые начинаются с введенного в поле редактирования текста, но не позволяют выбирать те строки, которые содержат введенный текст в любом месте строки (см. рис. 1) - во всяком случае в интернете я не нашел информации по этому вопросу. Поэтому пришлось делать свое выпадающее окно автозавершения.


Рисунок1

В целом выпадающее окно должно вести себя точно так же, как и стандартное выпадающее окно ComboBox'а (это окно создается системой, его класс называется "ComboLBox"):

  1. ComboBox не должен терять фокус при выпадении окна.
  2. Окно должно пропадать, когда фокус уходит с ComboBox'а; когда происходит переключение на другое окно; когда пользователь двигает форму, содержащую ComboBox.
  3. Выпадающее окно не должно получать фокус, т.е. когда пользователь выбирает какой-либо пункт списка автозавершения, либо просто прокручивает этот список мышью, заголовок формы, содержащей ComboBox, должен оставаться активным.

Второй пункт реализуется достаточно просто. Пункты первый и третий взаимосвязаны, и их я реализовать долгое время не мог. Родное выпадающее окно комбобокса (ComboLBox) судя по всему работает с применением захвата ввода от мыши (SetCapture/ReleaseCapture). Когда я попытался сделать свое выпадающее окно аналогичным образом, практически все получилось как задумано (комбобокс не терял фокус, форма не становилась неактивной), за единственным исключением - не работала полоса прокрутки в списке (в вопросе ¹ 57188 на Круглом столе тоже обсуждается эта проблема). Наконец, решение было найдено здесь. На основе этой статьи был сделан компонент TACComboBox. Тема показалась мне актуальной, поэтому я решил опубликовать результаты своих изысканий.

Описание компонента

Компонент имеет следующие published-свойства:

property ACItems: TStrings; - список вариантов автозавершения; из строк, содержащихся в этом списке формируется список автозавершения, в зависимости от значения свойства ACType.

type TAutoCompleteType = (actSimple, actSubString, actCustom);

property ACType: TAutoCompleteType; - определяет алгоритм, по которому заполняется список автозавершения.

  • при ACType=actSimple в список автозавершения помещаются строки из ACItems, которые начинаются с введенного в поле комбобокса текста;
  • при ACType=actSubString в список автозавершения помещаются строки из ACItems, которые содержат введенный в поле комбобокса текст;
  • при ACType=actCustom для каждого элемента списка ACItems вызывается обработчик события OnCheckString, в котором определяется, следует ли добавлять очередной элемент в список автозавершения. Если обработчик устанавливает параметр AddString в True, элемент добавляется, если в False - не добавляется.

В принципе, для формирования списка автозавершения можно не пользоваться списком, заданным в свойстве ACItems. Для этого нужно написать наследника класса TACComboBox, в котором переопределить protected-метод PrepareACStrings. В параметре AText этому методу передается введенный в поле комбобокса текст; метод должен заполнить список FDropDown.Items (подробнее см. пример, прилагаемый к статье).

Опишу некоторые детали реализации компонента; подробные комментарии есть в исходниках к статье.

  1. Итак, делаем выпадающее окно. Возможность изменения размеров окна автозавершения, как это сделано в IAutoComplete, мне не нужна, поэтому в качестве выпадающего окна вполне подойдет обычный ListBox.

    type
      TDropDownListBox = class(TListBox)
      protected
        procedure CreateParams(var Params: TCreateParams); override;
      end;
    
    procedure TDropDownListBox.CreateParams(var Params: TCreateParams);
    begin
    inherited CreateParams(Params);
    Params.ExStyle:=WS_EX_TOOLWINDOW;
    Params.WndParent:=GetDesktopWindow;
    Params.Style:=WS_CHILD or WS_BORDER or WS_CLIPSIBLINGS or WS_OVERLAPPED or WS_VSCROLL;
    end;
    

    Такой ListBox будет работать именно так, как нужно: не ловит фокус, при этом полоса прокрутки работает.

  2. Объявляем наследника класса TComboBox:

    type
      TACComboBox = class(TComboBox)
      private
        FDropDown: TDropDownListBox;
      public
        procedure ShowAC;
        procedure HideAC(ApplySelection: Boolean);
      end;
    

    FDropDown - ссылка на окно автозавершения, создается/разрушается в конструкторе/деструкторе TACComboBox, подробнее см. исходники, прилагаемые к статье.

    Метод ShowAC показывает выпадающее окно:

    procedure TACComboBox.ShowAC;
    var P: TPoint; Cnt: Integer;
    begin
    // если текст в комбобоксе ='', то прячем окно автозавершения (если оно было показано)
    if Text='' then
      begin
      HideAC(False);
      exit;
      end;
    
    // заполняем список автозавершения (подробнее см. исходники)
    PrepareACStrings(Text);
    
    Cnt:=FDropDown.Items.Count;
    // если подходящих вариантов автозавершения нет, прячем окно автозавершения
    if Cnt=0 then
      begin
      HideAC(False);
      exit;
      end;
    // будем показывать список автозавершения такого размера, чтобы в нем
    // помещалось не более пяти строк. Если вариантов автозавершения больше пяти,
    // будет показана вертикальная полоса прокрутки
    if Cnt>5 then Cnt:=5;
    
    FDropped:=True;
    
    // если было показано "родное" выпадающее окно комбобокса, прячем его
    SendMessage(Handle, CB_SHOWDROPDOWN, 0, 0);
    
    // показываем окно автозавершения под комбобоксом. Вообще говоря,
    // было бы правильным сделать, чтобы это окно показывалось над комбобоксом,
    // если комбобокс находится слишком близко к нижнему краю экрана
    P.X:=1;
    P.Y:=Height-1;
    P:=ClientToScreen(P);
    SetWindowPos(FDropDown.Handle, HWND_TOPMOST, P.X, P.Y, Width-GetSystemMetrics(SM_CXVSCROLL)-2,
      Cnt*FDropDown.ItemHeight+2, SWP_SHOWWINDOW);
    end;
    

    Метод HideAC прячет список автозавершения. Если ApplySelection=False, окно просто прячется, если True - прячется, а выбранная строка помещается в комбобокс:

    procedure TACComboBox.HideAC(ApplySelection: Boolean);
    var I: Integer;
    begin
    ShowWindow(FDropDown.Handle, SW_HIDE);
    if ApplySelection then
      begin
      I:=FDropDown.ItemIndex;
      if I<>-1 then
        begin
        Text:=FDropDown.Items[I];
        SelectAll;
        end;
      end;
    FDropped:=False;
    end;
    

  3. Нужно, чтобы при перемещении формы, на которой лежит комбобокс, окно автозавершения пряталось. Это реализуется путем подмены оконной процедуры формы и перехватом сообщений WM_WINDOWPOSCHANGING/WM_WINDOWPOSCHANGED. Мне самому такое решение не очень нравится, возможно, форма рассылает какие-то нотификационные сообщения дочерним контролам, уведомляя их об изменении своего положения, но я таких сообщений не нашел.

Остальные методы классов на мой взгляд понятны, подробности см. в исходниках.

Отмазки

Отмазка №1. Все это затевалось в основном для того, чтобы сделать AutoComplete с возможностью подбора вариантов по подстроке. Я исходил из предположения, что интерфейсы IAutoComplete/IAutoComplete2 этого сделать не могут. Если окажется, что они таки могут, и я изобрел очередной велосипед в - простите великодушно, не кидайте в меня камень, лучше киньте ссылку, по которой есть такая информация. Впрочем, даже в этом случае, надеюсь, из моей реализации все равно можно извлечь какую-то пользу - например, можно в списке вариантов подсвечивать набранный текст, как это показано на рисунке в начале статьи.

Отмазка №2. В принципе, можно в качестве выпадающего окна использовать не только листбокс. На мой взгляд, тема компонентов типа "поле ввода + кнопка + выпадающее окно" достаточно актуальна. Я попробовал на скорую руку вместо листбокса использовать календарь, на первый взгляд все работало как задумано. Но, с другой стороны, возможны и проблемы, связанные с тем, что фокус остается в комбобоксе и не переходит к выпадающему окну, поэтому как будет себя вести, например, TStringGrid с активным InplaceEdit'ом в роли выпадающего окна, я предсказывать не берусь.

Отмазка №3. Данные исходики я рассматриваю скорее как первый набросок, чем как окончательный вариант компонента. Всесторонне обкатать компонент в боевом проекте я еще не успел, поэтому наверняка где-то что-то можно добавить/оптимизировать.

Исходники

К статье прилагаю архив. Компонент находится в файле _ACCombo.pas, устанавливается обычным образом (Component/Install component…). Демо-проект не требует установки компонента в IDE, там компонент создается в Run-Time, поэтому можно просто открыть .dpr и нажать F9, чтобы посмотреть, как оно работает. Исходники на Delphi 7, но, насколько мне известно, я не использовал никаких особенностей компилятора данной версии, которые помешали бы пользоваться компонентом в других версиях Delphi.

Также, в соответствии с правилами Королевства, следует упомянуть и поблагодарить Владимира Папаева - для примера мне нужна была функция для представления числа прописью, я воспользовался его разработкой.


Страница сайта http://www.interface.ru
Оригинал находится по адресу http://www.interface.ru/home.asp?artId=21418