Skip to content

Commit 68d6501

Browse files
committed
Allow dropdowns to be opened by MouseDown event
Adds `ToggleOnMouseDown` boolean property for Dropdown and DropdownHeader. The flag will use `MouseDownEvent` instead of `ClickEvent` for toggling the menu. Dropdown handles `MouseUpEvent`, looking for active hovers and selecting preselected items if the menu is hovered. Dropdown should close if cursor was released outside the menu and should stay open if it is released on the `DropdownHeader`.
1 parent a79823b commit 68d6501

File tree

3 files changed

+163
-12
lines changed

3 files changed

+163
-12
lines changed

osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ public partial class TestSceneDropdown : ManualInputManagerTestScene
2929
{
3030
private const int items_to_add = 10;
3131

32-
[Test]
33-
public void TestBasic()
32+
[TestCase(false)]
33+
[TestCase(true)]
34+
public void TestToggleOnMouseDown(bool toggleOnMouseDown)
3435
{
3536
AddStep("setup dropdowns", () =>
3637
{
3738
TestDropdown[] dropdowns = createDropdowns(2);
39+
dropdowns.ForEach(dropdown => dropdown.ToggleOnMouseDown = toggleOnMouseDown);
3840
dropdowns[1].AlwaysShowSearchBar = true;
3941
});
4042
}
@@ -64,6 +66,64 @@ public void TestSelectByUserInteraction()
6466
.Item as DropdownMenuItem<TestModel?>)?.Value?.Identifier == "test 2");
6567
}
6668

69+
[Test]
70+
public void TestSelectByUserPressAndRelease()
71+
{
72+
TestDropdown testDropdown = null!;
73+
74+
AddStep("setup dropdown", () =>
75+
{
76+
testDropdown = createDropdown();
77+
testDropdown.ToggleOnMouseDown = true;
78+
});
79+
80+
toggleDropdownViaPress(() => testDropdown);
81+
assertDropdownIsOpen(() => testDropdown);
82+
83+
AddStep("release on item 2", () =>
84+
{
85+
InputManager.MoveMouseTo(testDropdown.Menu.Children[2]);
86+
InputManager.ReleaseButton(MouseButton.Left);
87+
});
88+
89+
assertDropdownIsClosed(() => testDropdown);
90+
91+
AddAssert("item 2 is selected", () => testDropdown.Current.Value?.Equals(testDropdown.Items.ElementAt(2)) == true);
92+
AddAssert("item 2 is selected item", () => testDropdown.SelectedItem.Value?.Identifier == "test 2");
93+
AddAssert("item 2 is visually selected", () => (testDropdown.ChildrenOfType<Dropdown<TestModel?>.DropdownMenu.DrawableDropdownMenuItem>()
94+
.SingleOrDefault(i => i.IsSelected)?
95+
.Item as DropdownMenuItem<TestModel?>)?.Value?.Identifier == "test 2");
96+
}
97+
98+
[Test]
99+
public void TestUserPressAndReleaseOutsideMenu()
100+
{
101+
TestDropdown testDropdown = null!;
102+
103+
AddStep("setup dropdown", () =>
104+
{
105+
testDropdown = createDropdown();
106+
testDropdown.ToggleOnMouseDown = true;
107+
});
108+
109+
toggleDropdownViaPress(() => testDropdown);
110+
assertDropdownIsOpen(() => testDropdown);
111+
112+
AddStep("preselect item 2", () =>
113+
InputManager.MoveMouseTo(testDropdown.Menu.Children[2])
114+
);
115+
AddStep("move outside the menu", () =>
116+
InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.Centre)
117+
);
118+
AddStep("release mouse buttons", () =>
119+
InputManager.ReleaseButton(MouseButton.Left)
120+
);
121+
122+
assertDropdownIsClosed(() => testDropdown);
123+
124+
AddAssert("item 2 is not selected", () => testDropdown.Current.Value?.Equals(testDropdown.Items.ElementAt(2)) == false);
125+
}
126+
67127
[Test]
68128
public void TestSelectByCurrent()
69129
{
@@ -809,6 +869,12 @@ private void toggleDropdownViaClick(Func<TestDropdown> dropdown, string? dropdow
809869
InputManager.Click(MouseButton.Left);
810870
});
811871

872+
private void toggleDropdownViaPress(Func<TestDropdown> dropdown, string? dropdownName = null) => AddStep($"press {dropdownName ?? "dropdown"}", () =>
873+
{
874+
InputManager.MoveMouseTo(dropdown().Header);
875+
InputManager.PressButton(MouseButton.Left);
876+
});
877+
812878
private void assertDropdownIsOpen(Func<TestDropdown> dropdown) => AddAssert("dropdown is open", () => dropdown().Menu.State == MenuState.Open);
813879

814880
private void assertDropdownIsClosed(Func<TestDropdown> dropdown) => AddAssert("dropdown is closed", () => dropdown().Menu.State == MenuState.Closed);

osu.Framework/Graphics/UserInterface/Dropdown.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ public bool AlwaysShowSearchBar
4444
set => Header.AlwaysShowSearchBar = value;
4545
}
4646

47+
/// <summary>
48+
/// Whether this <see cref="Dropdown{T}"/> should open/close on OnMouseDown event.
49+
/// </summary>
50+
public bool ToggleOnMouseDown
51+
{
52+
get => Header.ToggleOnMouseDown;
53+
set => Header.ToggleOnMouseDown = value;
54+
}
55+
4756
public bool AllowNonContiguousMatching
4857
{
4958
get => Menu.AllowNonContiguousMatching;
@@ -202,7 +211,7 @@ protected virtual LocalisableString GenerateItemText(T item)
202211
/// <summary>
203212
/// Puts the state of this <see cref="Dropdown{T}"/> one level back:
204213
/// - If the dropdown search bar contains text, this method will reset it.
205-
/// - If the dropdown is open, this method wil close it.
214+
/// - If the dropdown is open, this method will close it.
206215
/// </summary>
207216
public bool Back()
208217
{
@@ -340,6 +349,27 @@ protected override bool OnKeyDown(KeyDownEvent e)
340349
return false;
341350
}
342351

352+
protected override void OnMouseUp(MouseUpEvent e)
353+
{
354+
// Only proceed with the flag
355+
if (!ToggleOnMouseDown)
356+
return;
357+
358+
// Close dropdown when cursor is released outside the menu
359+
if (!Menu.IsHovered)
360+
{
361+
// Do not close the menu if we are releasing on the DropdownHeader
362+
if (!Header.IsHovered)
363+
Menu.Close();
364+
return;
365+
}
366+
367+
// Cursor is inside the menu and possibly selecting an item,
368+
// commit that selection and close the menu
369+
((IDropdown)this).CommitPreselection();
370+
Menu.Close();
371+
}
372+
343373
private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e)
344374
{
345375
switch (e.Action)

osu.Framework/Graphics/UserInterface/DropdownHeader.cs

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ public bool AlwaysShowSearchBar
3030
set => SearchBar.AlwaysDisplayOnFocus = value;
3131
}
3232

33+
/// <summary>
34+
/// Whether parent dropdown <see cref="Dropdown{T}"/> should open/close on OnMouseDown event.
35+
/// </summary>
36+
public bool ToggleOnMouseDown { get; set; }
37+
3338
protected internal DropdownSearchBar SearchBar { get; }
3439

3540
public Bindable<string> SearchTerm => SearchBar.SearchTerm;
@@ -95,14 +100,14 @@ protected DropdownHeader()
95100
Anchor = Anchor.CentreLeft,
96101
Origin = Anchor.CentreLeft,
97102
RelativeSizeAxes = Axes.X,
98-
AutoSizeAxes = Axes.Y
103+
AutoSizeAxes = Axes.Y,
99104
},
100105
SearchBar = CreateSearchBar(),
101-
new ClickHandler
106+
new UIEventHandler
102107
{
103108
RelativeSizeAxes = Axes.Both,
104-
Click = onClick
105-
}
109+
UIEventHandle = handleUIEvent
110+
},
106111
};
107112
}
108113

@@ -135,19 +140,68 @@ private void updateState()
135140
}
136141

137142
/// <summary>
138-
/// Handles clicks on the header to open/close the menu.
143+
/// Handles clicks and mouse events on the header to open/close the menu.
139144
/// </summary>
140-
private bool onClick(ClickEvent e)
145+
private bool handleUIEvent(UIEvent e)
141146
{
142147
// Allow input to fall through to the search bar (and its contained textbox) if there's any search text.
143148
if (SearchBar.State.Value == Visibility.Visible && !string.IsNullOrEmpty(SearchTerm.Value))
144149
return false;
145150

151+
switch (e)
152+
{
153+
case MouseDownEvent mouseDown:
154+
return onMouseDown(mouseDown);
155+
156+
case ClickEvent click:
157+
return onClick(click);
158+
159+
default:
160+
return false;
161+
}
162+
}
163+
164+
/// <summary>
165+
/// Handles clicks on the header to open/close the menu.
166+
/// </summary>
167+
private bool onClick(ClickEvent e)
168+
{
169+
// No need to handle dropdown as with this flag it has already been toggled by `onMouseDown` handler
170+
if (ToggleOnMouseDown)
171+
{
172+
// Without this check, when dropdown is opened by clicking outside `SearchBar`,
173+
// focus will be lost on `onClick` event -- therefore, closing dropdown.
174+
// We need to prevent that by manually focusing on the `SearchBar.textBox`.
175+
if (dropdown.MenuState == MenuState.Open)
176+
dropdown.ChangeFocus(SearchBar.Child);
177+
return false;
178+
}
179+
146180
// Otherwise, the header acts as a button to show/hide the menu.
147181
dropdown.ToggleMenu();
148182
return true;
149183
}
150184

185+
/// <summary>
186+
/// Handles mouse presses on the header to open/close the menu.
187+
/// </summary>
188+
private bool onMouseDown(MouseDownEvent e)
189+
{
190+
// Only proceed with the flag
191+
if (!ToggleOnMouseDown)
192+
return false;
193+
194+
// Only allow dropdown to toggle when pressing primary mouse button
195+
if (e.Button != MouseButton.Left)
196+
return false;
197+
198+
// Otherwise, the header acts as a button to show/hide the menu.
199+
dropdown.ToggleMenu();
200+
201+
// And importantly, when the menu is closed as a result of the above toggle, block the search bar from receiving input.
202+
return dropdown.MenuState == MenuState.Closed;
203+
}
204+
151205
public override bool HandleNonPositionalInput => IsHovered;
152206

153207
protected override bool OnKeyDown(KeyDownEvent e)
@@ -204,10 +258,11 @@ public enum DropdownSelectionAction
204258
LastVisible
205259
}
206260

207-
private partial class ClickHandler : Drawable
261+
private partial class UIEventHandler : Drawable
208262
{
209-
public required Func<ClickEvent, bool> Click { get; init; }
210-
protected override bool OnClick(ClickEvent e) => Click(e);
263+
public required Func<UIEvent, bool> UIEventHandle { get; init; }
264+
265+
protected override bool Handle(UIEvent e) => UIEventHandle(e);
211266
}
212267
}
213268
}

0 commit comments

Comments
 (0)