Skip to content

Commit 11b6757

Browse files
Add option for custom popup placement for ComboBox (#3005)
* Add special ComboBox popup handling for 90 degrees rotation Popup placement was wrong in case a LayoutTransform was applied to rotate the ComboBox. The default WPF ComboBox does respect this, and thus so should MDIX. To RotateTransfrom.Angle is compared against values of 90 and -90 (10 digit precision) to determine whether this is a rotation where the special handling should be applied. * Refactor custom popup placement into publicly exposed callback Rather than handling arbitrary rotations in the library itself, we expose a callback which can then be used to rotate the popup shown by the combobox such that it matches the users needs. * Update MaterialDesignThemes.Wpf/ComboBoxPopup.cs Co-authored-by: Kevin B <[email protected]> * Fix compile error Co-authored-by: Kevin B <[email protected]>
1 parent 4c4a754 commit 11b6757

File tree

7 files changed

+276
-14
lines changed

7 files changed

+276
-14
lines changed

MainDemo.Wpf/ComboBoxes.xaml

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<UserControl x:Class="MaterialDesignDemo.ComboBoxes"
1+
<UserControl x:Class="MaterialDesignDemo.ComboBoxes"
22
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
33
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
44
xmlns:colorsDomain="clr-namespace:MaterialDesignDemo.Domain"
@@ -7,6 +7,8 @@
77
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
88
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
99
xmlns:smtx="clr-namespace:ShowMeTheXAML;assembly=ShowMeTheXAML"
10+
xmlns:converters="clr-namespace:MaterialDesignDemo.Converters"
11+
xmlns:materialDesignDemo="clr-namespace:MaterialDesignDemo"
1012
d:DataContext="{d:DesignInstance colorsDomain:ComboBoxesViewModel,
1113
IsDesignTimeCreatable=False}"
1214
d:DesignHeight="300"
@@ -339,5 +341,156 @@
339341
</ComboBox>
340342
</smtx:XamlDisplay>
341343
</StackPanel>
344+
345+
<TextBlock Style="{StaticResource SectionTitle}" Text="Rotation Clockwise" />
346+
347+
<StackPanel Margin="0,8,0,0">
348+
<CheckBox x:Name="CheckBoxClockwiseRotateContent" IsChecked="False" Content="Rotate drop-down content" Margin="16,0" />
349+
<StackPanel Margin="16,15,0,0" Orientation="Horizontal">
350+
351+
<smtx:XamlDisplay UniqueKey="clockwise_1" Margin="0">
352+
<ComboBox Style="{StaticResource MaterialDesignFloatingHintComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
353+
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesClockWiseCallback}">
354+
<ComboBox.LayoutTransform>
355+
<RotateTransform Angle="90"/>
356+
</ComboBox.LayoutTransform>
357+
<ComboBox.ItemsPanel>
358+
<ItemsPanelTemplate>
359+
<StackPanel Orientation="Vertical">
360+
<StackPanel.LayoutTransform>
361+
<RotateTransform Angle="{Binding ElementName=CheckBoxClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=-90, FalseValue=0}}"/>
362+
</StackPanel.LayoutTransform>
363+
</StackPanel>
364+
</ItemsPanelTemplate>
365+
</ComboBox.ItemsPanel>
366+
<ComboBoxItem Content="Item 1" />
367+
<ComboBoxItem Content="Item 2" />
368+
<ComboBoxItem Content="Item 3" />
369+
<ComboBoxItem Content="Item 4" />
370+
</ComboBox>
371+
</smtx:XamlDisplay>
372+
373+
<smtx:XamlDisplay UniqueKey="clockwise_2" Margin="150,0">
374+
<ComboBox Style="{StaticResource MaterialDesignFilledComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
375+
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesClockWiseCallback}">
376+
<ComboBox.LayoutTransform>
377+
<RotateTransform Angle="90"/>
378+
</ComboBox.LayoutTransform>
379+
<ComboBox.ItemsPanel>
380+
<ItemsPanelTemplate>
381+
<StackPanel Orientation="Vertical">
382+
<StackPanel.LayoutTransform>
383+
<RotateTransform Angle="{Binding ElementName=CheckBoxClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=-90, FalseValue=0}}"/>
384+
</StackPanel.LayoutTransform>
385+
</StackPanel>
386+
</ItemsPanelTemplate>
387+
</ComboBox.ItemsPanel>
388+
<ComboBoxItem Content="Item 1" />
389+
<ComboBoxItem Content="Item 2" />
390+
<ComboBoxItem Content="Item 3" />
391+
<ComboBoxItem Content="Item 4" />
392+
</ComboBox>
393+
</smtx:XamlDisplay>
394+
395+
<smtx:XamlDisplay UniqueKey="clockwise_3" Margin="0">
396+
<ComboBox Style="{StaticResource MaterialDesignOutlinedComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
397+
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesClockWiseCallback}">
398+
<ComboBox.LayoutTransform>
399+
<RotateTransform Angle="90"/>
400+
</ComboBox.LayoutTransform>
401+
<ComboBox.ItemsPanel>
402+
<ItemsPanelTemplate>
403+
<StackPanel Orientation="Vertical">
404+
<StackPanel.LayoutTransform>
405+
<RotateTransform Angle="{Binding ElementName=CheckBoxClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=-90, FalseValue=0}}"/>
406+
</StackPanel.LayoutTransform>
407+
</StackPanel>
408+
</ItemsPanelTemplate>
409+
</ComboBox.ItemsPanel>
410+
<ComboBoxItem Content="Item 1" />
411+
<ComboBoxItem Content="Item 2" />
412+
<ComboBoxItem Content="Item 3" />
413+
<ComboBoxItem Content="Item 4" />
414+
</ComboBox>
415+
</smtx:XamlDisplay>
416+
417+
</StackPanel>
418+
</StackPanel>
419+
420+
<TextBlock Style="{StaticResource SectionTitle}" Text="Rotation Counter-Clockwise" />
421+
422+
<StackPanel Margin="0,8,0,0">
423+
<CheckBox x:Name="CheckBoxCounterClockwiseRotateContent" IsChecked="False" Content="Rotate drop-down content" Margin="16,0" />
424+
<StackPanel Margin="16,15,0,0" Orientation="Horizontal">
425+
426+
<smtx:XamlDisplay UniqueKey="counter_clockwise_1" Margin="0">
427+
<ComboBox Style="{StaticResource MaterialDesignFloatingHintComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
428+
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesCounterClockWiseCallback}">
429+
<ComboBox.LayoutTransform>
430+
<RotateTransform Angle="-90"/>
431+
</ComboBox.LayoutTransform>
432+
<ComboBox.ItemsPanel>
433+
<ItemsPanelTemplate>
434+
<StackPanel Orientation="Vertical">
435+
<StackPanel.LayoutTransform>
436+
<RotateTransform Angle="{Binding ElementName=CheckBoxCounterClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=90, FalseValue=0}}"/>
437+
</StackPanel.LayoutTransform>
438+
</StackPanel>
439+
</ItemsPanelTemplate>
440+
</ComboBox.ItemsPanel>
441+
<ComboBoxItem Content="Item 1" />
442+
<ComboBoxItem Content="Item 2" />
443+
<ComboBoxItem Content="Item 3" />
444+
<ComboBoxItem Content="Item 4" />
445+
</ComboBox>
446+
</smtx:XamlDisplay>
447+
448+
<smtx:XamlDisplay UniqueKey="counter_clockwise_2" Margin="150,0">
449+
<ComboBox Style="{StaticResource MaterialDesignFilledComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
450+
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesCounterClockWiseCallback}">
451+
<ComboBox.LayoutTransform>
452+
<RotateTransform Angle="-90"/>
453+
</ComboBox.LayoutTransform>
454+
<ComboBox.ItemsPanel>
455+
<ItemsPanelTemplate>
456+
<StackPanel Orientation="Vertical">
457+
<StackPanel.LayoutTransform>
458+
<RotateTransform Angle="{Binding ElementName=CheckBoxCounterClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=90, FalseValue=0}}"/>
459+
</StackPanel.LayoutTransform>
460+
</StackPanel>
461+
</ItemsPanelTemplate>
462+
</ComboBox.ItemsPanel>
463+
<ComboBoxItem Content="Item 1" />
464+
<ComboBoxItem Content="Item 2" />
465+
<ComboBoxItem Content="Item 3" />
466+
<ComboBoxItem Content="Item 4" />
467+
</ComboBox>
468+
</smtx:XamlDisplay>
469+
470+
<smtx:XamlDisplay UniqueKey="counter_clockwise_3" Margin="0">
471+
<ComboBox Style="{StaticResource MaterialDesignOutlinedComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
472+
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesCounterClockWiseCallback}">
473+
<ComboBox.LayoutTransform>
474+
<RotateTransform Angle="-90"/>
475+
</ComboBox.LayoutTransform>
476+
<ComboBox.ItemsPanel>
477+
<ItemsPanelTemplate>
478+
<StackPanel Orientation="Vertical">
479+
<StackPanel.LayoutTransform>
480+
<RotateTransform Angle="{Binding ElementName=CheckBoxCounterClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=90, FalseValue=0}}"/>
481+
</StackPanel.LayoutTransform>
482+
</StackPanel>
483+
</ItemsPanelTemplate>
484+
</ComboBox.ItemsPanel>
485+
<ComboBoxItem Content="Item 1" />
486+
<ComboBoxItem Content="Item 2" />
487+
<ComboBoxItem Content="Item 3" />
488+
<ComboBoxItem Content="Item 4" />
489+
</ComboBox>
490+
</smtx:XamlDisplay>
491+
492+
</StackPanel>
493+
</StackPanel>
494+
342495
</StackPanel>
343-
</UserControl>
496+
</UserControl>

MainDemo.Wpf/ComboBoxes.xaml.cs

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,60 @@
11
using MaterialDesignDemo.Domain;
2+
using Screen = System.Windows.Forms.Screen;
3+
using DrawingPoint = System.Drawing.Point;
24

3-
namespace MaterialDesignDemo
5+
namespace MaterialDesignDemo;
6+
7+
public partial class ComboBoxes
48
{
5-
public partial class ComboBoxes
9+
private const double DropShadowHeight = 6; // This does not account for DPI scaling!
10+
11+
public static CustomPopupPlacementCallback Rotate90DegreesClockWiseCallback { get; } = (popupSize, targetSize, offset) =>
612
{
7-
public ComboBoxes()
13+
// ComboBox is rotated 90 degrees clockwise (ie. Left=Up, Right=Down)
14+
var comboBox = VisualTreeUtil.GetElementUnderMouse<ComboBox>();
15+
var comboBoxLocation = comboBox.PointToScreen(new Point(0, 0));
16+
Screen screen = Screen.FromPoint(new DrawingPoint((int)comboBoxLocation.X, (int)comboBoxLocation.Y));
17+
int comboBoxOffsetX = (int)(comboBoxLocation.X - screen.Bounds.X) % screen.Bounds.Width;
18+
double y = offset.Y - DropShadowHeight;
19+
double x = offset.X;
20+
double rotatedComboBoxHeight = targetSize.Width;
21+
if (comboBoxOffsetX + x > popupSize.Width + rotatedComboBoxHeight)
822
{
9-
InitializeComponent();
10-
DataContext = new ComboBoxesViewModel();
23+
x -= popupSize.Width + rotatedComboBoxHeight;
1124
}
25+
return new[] { new CustomPopupPlacement(new Point(x, y), PopupPrimaryAxis.Horizontal) };
26+
};
1227

13-
private void ClearFilledComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
14-
=> FilledComboBox.SelectedItem = null;
28+
public static CustomPopupPlacementCallback Rotate90DegreesCounterClockWiseCallback { get; } = (popupSize, targetSize, offset) =>
29+
{
30+
// ComboBox is rotated 90 degrees counter-clockwise (ie. Left=Down, Right=Up)
31+
var comboBox = VisualTreeUtil.GetElementUnderMouse<ComboBox>();
32+
var comboBoxLocation = comboBox.PointToScreen(new Point(0, 0));
33+
Screen screen = Screen.FromPoint(new DrawingPoint((int)comboBoxLocation.X, (int)comboBoxLocation.Y));
34+
int comboBoxOffsetX = (int)(comboBoxLocation.X - screen.Bounds.X) % screen.Bounds.Width;
35+
double y = offset.Y - popupSize.Height + DropShadowHeight;
36+
double x = offset.X;
37+
double rotatedComboBoxHeight = targetSize.Width;
38+
if (comboBoxOffsetX + x + rotatedComboBoxHeight + popupSize.Width > screen.Bounds.Width)
39+
{
40+
x -= popupSize.Width;
41+
}
42+
else
43+
{
44+
x += rotatedComboBoxHeight;
45+
}
46+
return new[] { new CustomPopupPlacement(new Point(x, y), PopupPrimaryAxis.Horizontal) };
47+
};
1548

16-
private void ClearOutlinedComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
17-
=> OutlinedComboBox.SelectedItem = null;
49+
public ComboBoxes()
50+
{
51+
InitializeComponent();
52+
DataContext = new ComboBoxesViewModel();
1853
}
54+
55+
private void ClearFilledComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
56+
=> FilledComboBox.SelectedItem = null;
57+
58+
private void ClearOutlinedComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
59+
=> OutlinedComboBox.SelectedItem = null;
1960
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Globalization;
2+
using System.Windows.Data;
3+
4+
namespace MaterialDesignDemo.Converters;
5+
6+
public class BooleanToDoubleConverter : MarkupExtension, IValueConverter
7+
{
8+
public double TrueValue { get; set; }
9+
public double FalseValue { get; set; }
10+
11+
public override object ProvideValue(IServiceProvider serviceProvider) => this;
12+
13+
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is true ? TrueValue : FalseValue;
14+
15+
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException();
16+
17+
}

MainDemo.Wpf/VisualTreeUtil.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System.Windows.Media;
2+
3+
namespace MaterialDesignDemo;
4+
5+
internal static class VisualTreeUtil
6+
{
7+
private static T FindVisualParent<T>(UIElement element) where T : UIElement?
8+
{
9+
UIElement? parent = element;
10+
while (parent != null)
11+
{
12+
if (parent is T correctlyTyped)
13+
{
14+
return correctlyTyped;
15+
}
16+
parent = VisualTreeHelper.GetParent(parent) as UIElement;
17+
}
18+
return default!;
19+
}
20+
21+
internal static T GetElementUnderMouse<T>() where T : UIElement? => FindVisualParent<T>((Mouse.DirectlyOver as UIElement)!);
22+
}

MaterialDesignThemes.Wpf/ComboBoxAssist.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,17 @@ public static void SetShowSelectedItem(DependencyObject element, bool value)
5959
public static int GetMaxLength(DependencyObject element) => (int)element.GetValue(MaxLengthProperty);
6060
public static void SetMaxLength(DependencyObject element, int value) => element.SetValue(MaxLengthProperty, value);
6161
#endregion
62+
63+
#region AttachedProperty : CustomPopupPlacementCallback
64+
public static readonly DependencyProperty CustomPopupPlacementCallbackProperty =
65+
DependencyProperty.RegisterAttached(
66+
"CustomPopupPlacementCallback",
67+
typeof(CustomPopupPlacementCallback),
68+
typeof(ComboBoxAssist),
69+
new FrameworkPropertyMetadata(default(CustomPopupPlacementCallback),
70+
FrameworkPropertyMetadataOptions.AffectsRender));
71+
72+
public static void SetCustomPopupPlacementCallback(DependencyObject element, CustomPopupPlacementCallback value) => element.SetValue(CustomPopupPlacementCallbackProperty, value);
73+
public static CustomPopupPlacementCallback GetCustomPopupPlacementCallback(DependencyObject element) => (CustomPopupPlacementCallback) element.GetValue(CustomPopupPlacementCallbackProperty);
74+
#endregion
6275
}

MaterialDesignThemes.Wpf/ComboBoxPopup.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,16 @@ public PopupDirection OpenDirection
250250
DependencyProperty.Register(nameof(OpenDirection), typeof(PopupDirection),
251251
typeof(ComboBoxPopup), new PropertyMetadata(PopupDirection.None));
252252

253+
public static readonly DependencyProperty CustomPopupPlacementCallbackOverrideProperty =
254+
DependencyProperty.Register(nameof(CustomPopupPlacementCallbackOverride), typeof(CustomPopupPlacementCallback),
255+
typeof(ComboBoxPopup), new PropertyMetadata(default(CustomPopupPlacementCallback)));
256+
257+
public CustomPopupPlacementCallback? CustomPopupPlacementCallbackOverride
258+
{
259+
get => (CustomPopupPlacementCallback?) GetValue(CustomPopupPlacementCallbackOverrideProperty);
260+
set => SetValue(CustomPopupPlacementCallbackOverrideProperty, value);
261+
}
262+
253263
public ComboBoxPopup()
254264
=> CustomPopupPlacementCallback = ComboBoxCustomPopupPlacementCallback;
255265

@@ -273,6 +283,11 @@ protected override void OnClosed(EventArgs e)
273283
private CustomPopupPlacement[] ComboBoxCustomPopupPlacementCallback(
274284
Size popupSize, Size targetSize, Point offset)
275285
{
286+
if (CustomPopupPlacementCallbackOverride != null)
287+
{
288+
return CustomPopupPlacementCallbackOverride(popupSize, targetSize, offset);
289+
}
290+
276291
var visualAncestry = PlacementTarget.GetVisualAncestry().ToList();
277292

278293
var parent = visualAncestry.OfType<Panel>().ElementAt(1);
@@ -303,10 +318,10 @@ PositioningData GetPositioningData(IEnumerable<DependencyObject?> visualAncestry
303318
var locationFromScreen = PlacementTarget.PointToScreen(new Point(0, 0));
304319

305320
var mainVisual = visualAncestry.OfType<Visual>().LastOrDefault();
306-
if (mainVisual is null) throw new ArgumentException($"{nameof(visualAncestry)} must contains unless one {nameof(Visual)} control inside.");
321+
if (mainVisual is null) throw new ArgumentException($"{nameof(visualAncestry)} must contains at least one {nameof(Visual)} control inside.");
307322

308323
var controlVisual = visualAncestry.OfType<Visual>().FirstOrDefault();
309-
if (controlVisual == null) throw new ArgumentException($"{nameof(visualAncestry)} must contains unless one {nameof(Visual)} control inside.");
324+
if (controlVisual == null) throw new ArgumentException($"{nameof(visualAncestry)} must contains at least one {nameof(Visual)} control inside.");
310325

311326
var screen = Screen.FromPoint(locationFromScreen);
312327
var screenWidth = (int)screen.Bounds.Width;

MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.ComboBox.xaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,8 @@
427427
Tag="{DynamicResource MaterialDesignPaper}"
428428
UpVerticalOffset="15"
429429
UseLayoutRounding="{TemplateBinding UseLayoutRounding}"
430-
VerticalOffset="0">
430+
VerticalOffset="0"
431+
CustomPopupPlacementCallbackOverride="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:ComboBoxAssist.CustomPopupPlacementCallback)}">
431432
<wpf:ComboBoxPopup.Background>
432433
<MultiBinding Converter="{StaticResource FallbackBrushConverter}">
433434
<Binding Path="Background" RelativeSource="{RelativeSource TemplatedParent}" />

0 commit comments

Comments
 (0)