The Combo Box Windows control (aka TComboBox in Delphi) is one of the most frequently used user interface elements along with buttons and edits in Windows applications. TComboBox control represents an edit box with a scrollable drop-down list attached to it. Users can select an item from the list or type directly into the edit box. When the Style property is set to csDropDownList the combo allows to display a list of predefined items a user can select from the combo’s drop down list.
I’ve had a request from a user of my application: can the selection list be dropped down and then closed if no selection has been made in some period of time? Well, the user is asking. So, certainly it can!
The task to drop combo’s list is super easy: just set DroppedDown property to true (this sends CB_SHOWDROPDOWN to the combo). Then wait – but do not block the main thread !! – so some multithreading needed. Ok, use Delphi’s PPL. Finally, once the wait period is over, close the combo’s list – but, let’s say, only if the user is not hovering the mouse over combo’s items!
Looks pretty straightforward.
The only part to figure out is how to know if the mouse is over the list. Luckily, there’s a super handy Windows API call: GetComboBoxInfo. This function fills in the tagCOMBOBOXINFO structure (TComboBoxInfo record in Delphi’s implementation) with handles to the combo’s list box, edit box and the combo box itself. Therefore: get the mouse coordinates, and if over combo’s list (or combo itself) – simply leave it open.
Threaded Approach to Dropping Down and Closing Combo’s List
And here’s the resulting procedure: ComboDropAndCloseDelayed. Feed it with a TComboBox instance and set the needed delay time. Combo’s list will show, time will pass, and if the user is not over the combo’s list with the mouse (so still deciding of the selection) – close the list.
EDIT: Warning: the wanted quick and dirty approach is not the way to go. I “forgot” that TWinControl.Handle is not thread safe. Please read Remy’s comment….
//how not to do it :) procedure ComboDropAndCloseDelayed(const theCombo : TComboBox; const closeDelayMS : Cardinal = 2500); begin TTask.Run( procedure var cbi : TComboBoxInfo; begin //No-no! TWinControl.Handle is *not* thread safe! cbi.cbSize := SizeOf(TComboBoxInfo); if NOT GetComboBoxInfo(theCombo.Handle, cbi) then Exit; TThread.Queue(nil, procedure begin theCombo.DroppedDown := true; end); Sleep(closeDelayMS); TThread.Queue(nil, procedure var undrop : boolean; hw : THandle; begin undrop := true; if theCombo.HandleAllocated AND theCombo.DroppedDown then begin hw := WindowFromPoint(Mouse.CursorPos); undrop := (hw <> 0) AND (hw <> cbi.hwndCombo) AND (hw <> cbi.hwndList) AND (hw <> cbi.hwndItem); if undrop then theCombo.DroppedDown := false; end; end); end); end;
And how to use (example):
procedure TMainForm.Button1Click(Sender: TObject); begin //if no selection already: drop the list if ComboBox1.ItemIndex = -1 then ComboDropAndCloseDelayed(ComboBox1); end;
That’s it. Nice and quick and neat …
I would NOT suggest using a worker thread for this. A timer, or even TThread.ForceQueue() with its ADelay parameter, would be more appropriate. The TWinControl.Handle property is NOT thread-safe! You can really mess up your UI by reading that property in a worker thread, if the main UI thread happens to perform a HWND recreation at the exact same moment (which can and does happen from time to time).
I would suggest something more like this instead (10.4.x and later):
procedure CloseIfNotHovering(const theCombo : TComboBox; const closeDelayMS : Cardinal = 2500);
begin
TThread.ForceQueue(nil,
procedure
var
cbi : TComboBoxInfo;
hw : HWND;
begin
if not theCombo.DroppedDown then Exit;
cbi.cbSize := SizeOf(TComboBoxInfo);
if not GetComboBoxInfo(theCombo.Handle, cbi) then Exit;
hw := WindowFromPoint(Mouse.CursorPos);
if (hw cbi.hwndCombo) and (hw cbi.hwndList) and (hw cbi.hwndItem) then
theCombo.DroppedDown := False
else
CloseIfNotHovering(theCombo, closeDelayMS);
end,
closeDelayMS
);
end;
procedure ComboDropAndCloseDelayed(const theCombo : TComboBox; const closeDelayMS : Cardinal = 2500);
begin
theCombo.DroppedDown := True;
CloseIfNotHovering(theCombo, closeDelayMS);
// TODO: subclass the TComboBox’s Parent to cancel the queued close procedure if CBN_CLOSEUP is received…
end;
Alternatively (if using an earlier version of Delphi):
procedure ComboCloseTimerProc(Wnd: HWND; uMsg: UINT; uIDTimer: UINT; dwTime: DWORD); stdcall;
var
cbi : TComboBoxInfo;
hw : HWND;
begin
if SendMessage(Wnd, CB_GETDROPPEDSTATE, 0, 0) = 1 then
begin
cbi.cbSize := SizeOf(TComboBoxInfo);
if GetComboBoxInfo(Wnd, cbi) then
begin
hw := WindowFromPoint(Mouse.CursorPos);
if (hw = cbi.hwndCombo) or (hw = cbi.hwndList) or (hw = cbi.hwndItem) then Exit;
end;
KillTimer(Wnd, uIDTimer);
SendMessage(Wnd, CB_SHOWDROPDOWN, 0, 0);
end;
function ComboSubclassProc(Wnd: HWND; uMsg: UINT; wParam: WPARAM; lParam: LPARAM; uIdSubclass: UINT_PTR; dwRefData: DWORD_PTR): LRESULT; stdcall;
begin
case uMsg of
WM_NCDESTROY: begin
RemoveWindowSubclass(Wnd, @ComboSubclassProc, uIdSubclass);
end;
WM_COMMAND: begin
if (HIWORD(wParam) = CB_CLOSEUP) and (HWND(lParam) = HWND(dwRefData)) then
begin
KillTimer(HWND(lParam), 12345);
RemoveWindowSubclass(Wnd, @ComboSubclassProc, uIdSubclass);
end;
end;
end;
Result := DefSubclassProc(Wnd, uMsg, wParam, lParam);
end;
procedure ComboDropAndCloseDelayed(const theCombo : TComboBox; const closeDelayMS : Cardinal = 2500);
var
hw: HWND;
begin
hw := theCombo.Handle;
theCombo.DroppedDown := True;
SetTimer(hw, 12345, closeDelayMS, @ComboCloseTimerProc);
SetWindowSubclass(theCombo.Parent.Handle, @ComboSubclassProc, 1, DWORD_PTR(hw));
end;
@Remy: thanks for noticing and additional input! I stand corrected.
-ž