Skip to content

Commit

Permalink
Add option for custom popup placement for ComboBox (#3005)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
nicolaihenriksen and Keboo authored Jan 5, 2023
1 parent 4c4a754 commit 11b6757
Show file tree
Hide file tree
Showing 7 changed files with 276 additions and 14 deletions.
157 changes: 155 additions & 2 deletions MainDemo.Wpf/ComboBoxes.xaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<UserControl x:Class="MaterialDesignDemo.ComboBoxes"
<UserControl x:Class="MaterialDesignDemo.ComboBoxes"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:colorsDomain="clr-namespace:MaterialDesignDemo.Domain"
Expand All @@ -7,6 +7,8 @@
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:smtx="clr-namespace:ShowMeTheXAML;assembly=ShowMeTheXAML"
xmlns:converters="clr-namespace:MaterialDesignDemo.Converters"
xmlns:materialDesignDemo="clr-namespace:MaterialDesignDemo"
d:DataContext="{d:DesignInstance colorsDomain:ComboBoxesViewModel,
IsDesignTimeCreatable=False}"
d:DesignHeight="300"
Expand Down Expand Up @@ -339,5 +341,156 @@
</ComboBox>
</smtx:XamlDisplay>
</StackPanel>

<TextBlock Style="{StaticResource SectionTitle}" Text="Rotation Clockwise" />

<StackPanel Margin="0,8,0,0">
<CheckBox x:Name="CheckBoxClockwiseRotateContent" IsChecked="False" Content="Rotate drop-down content" Margin="16,0" />
<StackPanel Margin="16,15,0,0" Orientation="Horizontal">

<smtx:XamlDisplay UniqueKey="clockwise_1" Margin="0">
<ComboBox Style="{StaticResource MaterialDesignFloatingHintComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesClockWiseCallback}">
<ComboBox.LayoutTransform>
<RotateTransform Angle="90"/>
</ComboBox.LayoutTransform>
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical">
<StackPanel.LayoutTransform>
<RotateTransform Angle="{Binding ElementName=CheckBoxClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=-90, FalseValue=0}}"/>
</StackPanel.LayoutTransform>
</StackPanel>
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
<ComboBoxItem Content="Item 4" />
</ComboBox>
</smtx:XamlDisplay>

<smtx:XamlDisplay UniqueKey="clockwise_2" Margin="150,0">
<ComboBox Style="{StaticResource MaterialDesignFilledComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesClockWiseCallback}">
<ComboBox.LayoutTransform>
<RotateTransform Angle="90"/>
</ComboBox.LayoutTransform>
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical">
<StackPanel.LayoutTransform>
<RotateTransform Angle="{Binding ElementName=CheckBoxClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=-90, FalseValue=0}}"/>
</StackPanel.LayoutTransform>
</StackPanel>
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
<ComboBoxItem Content="Item 4" />
</ComboBox>
</smtx:XamlDisplay>

<smtx:XamlDisplay UniqueKey="clockwise_3" Margin="0">
<ComboBox Style="{StaticResource MaterialDesignOutlinedComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesClockWiseCallback}">
<ComboBox.LayoutTransform>
<RotateTransform Angle="90"/>
</ComboBox.LayoutTransform>
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical">
<StackPanel.LayoutTransform>
<RotateTransform Angle="{Binding ElementName=CheckBoxClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=-90, FalseValue=0}}"/>
</StackPanel.LayoutTransform>
</StackPanel>
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
<ComboBoxItem Content="Item 4" />
</ComboBox>
</smtx:XamlDisplay>

</StackPanel>
</StackPanel>

<TextBlock Style="{StaticResource SectionTitle}" Text="Rotation Counter-Clockwise" />

<StackPanel Margin="0,8,0,0">
<CheckBox x:Name="CheckBoxCounterClockwiseRotateContent" IsChecked="False" Content="Rotate drop-down content" Margin="16,0" />
<StackPanel Margin="16,15,0,0" Orientation="Horizontal">

<smtx:XamlDisplay UniqueKey="counter_clockwise_1" Margin="0">
<ComboBox Style="{StaticResource MaterialDesignFloatingHintComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesCounterClockWiseCallback}">
<ComboBox.LayoutTransform>
<RotateTransform Angle="-90"/>
</ComboBox.LayoutTransform>
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical">
<StackPanel.LayoutTransform>
<RotateTransform Angle="{Binding ElementName=CheckBoxCounterClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=90, FalseValue=0}}"/>
</StackPanel.LayoutTransform>
</StackPanel>
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
<ComboBoxItem Content="Item 4" />
</ComboBox>
</smtx:XamlDisplay>

<smtx:XamlDisplay UniqueKey="counter_clockwise_2" Margin="150,0">
<ComboBox Style="{StaticResource MaterialDesignFilledComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesCounterClockWiseCallback}">
<ComboBox.LayoutTransform>
<RotateTransform Angle="-90"/>
</ComboBox.LayoutTransform>
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical">
<StackPanel.LayoutTransform>
<RotateTransform Angle="{Binding ElementName=CheckBoxCounterClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=90, FalseValue=0}}"/>
</StackPanel.LayoutTransform>
</StackPanel>
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
<ComboBoxItem Content="Item 4" />
</ComboBox>
</smtx:XamlDisplay>

<smtx:XamlDisplay UniqueKey="counter_clockwise_3" Margin="0">
<ComboBox Style="{StaticResource MaterialDesignOutlinedComboBox}" Width="150" materialDesign:HintAssist.Hint="Selected Item"
materialDesign:ComboBoxAssist.CustomPopupPlacementCallback="{x:Static materialDesignDemo:ComboBoxes.Rotate90DegreesCounterClockWiseCallback}">
<ComboBox.LayoutTransform>
<RotateTransform Angle="-90"/>
</ComboBox.LayoutTransform>
<ComboBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical">
<StackPanel.LayoutTransform>
<RotateTransform Angle="{Binding ElementName=CheckBoxCounterClockwiseRotateContent, Path=IsChecked, Converter={converters:BooleanToDoubleConverter TrueValue=90, FalseValue=0}}"/>
</StackPanel.LayoutTransform>
</StackPanel>
</ItemsPanelTemplate>
</ComboBox.ItemsPanel>
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
<ComboBoxItem Content="Item 4" />
</ComboBox>
</smtx:XamlDisplay>

</StackPanel>
</StackPanel>

</StackPanel>
</UserControl>
</UserControl>
59 changes: 50 additions & 9 deletions MainDemo.Wpf/ComboBoxes.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,60 @@
using MaterialDesignDemo.Domain;
using Screen = System.Windows.Forms.Screen;
using DrawingPoint = System.Drawing.Point;

namespace MaterialDesignDemo
namespace MaterialDesignDemo;

public partial class ComboBoxes
{
public partial class ComboBoxes
private const double DropShadowHeight = 6; // This does not account for DPI scaling!

public static CustomPopupPlacementCallback Rotate90DegreesClockWiseCallback { get; } = (popupSize, targetSize, offset) =>
{
public ComboBoxes()
// ComboBox is rotated 90 degrees clockwise (ie. Left=Up, Right=Down)
var comboBox = VisualTreeUtil.GetElementUnderMouse<ComboBox>();
var comboBoxLocation = comboBox.PointToScreen(new Point(0, 0));
Screen screen = Screen.FromPoint(new DrawingPoint((int)comboBoxLocation.X, (int)comboBoxLocation.Y));
int comboBoxOffsetX = (int)(comboBoxLocation.X - screen.Bounds.X) % screen.Bounds.Width;
double y = offset.Y - DropShadowHeight;
double x = offset.X;
double rotatedComboBoxHeight = targetSize.Width;
if (comboBoxOffsetX + x > popupSize.Width + rotatedComboBoxHeight)
{
InitializeComponent();
DataContext = new ComboBoxesViewModel();
x -= popupSize.Width + rotatedComboBoxHeight;
}
return new[] { new CustomPopupPlacement(new Point(x, y), PopupPrimaryAxis.Horizontal) };
};

private void ClearFilledComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
=> FilledComboBox.SelectedItem = null;
public static CustomPopupPlacementCallback Rotate90DegreesCounterClockWiseCallback { get; } = (popupSize, targetSize, offset) =>
{
// ComboBox is rotated 90 degrees counter-clockwise (ie. Left=Down, Right=Up)
var comboBox = VisualTreeUtil.GetElementUnderMouse<ComboBox>();
var comboBoxLocation = comboBox.PointToScreen(new Point(0, 0));
Screen screen = Screen.FromPoint(new DrawingPoint((int)comboBoxLocation.X, (int)comboBoxLocation.Y));
int comboBoxOffsetX = (int)(comboBoxLocation.X - screen.Bounds.X) % screen.Bounds.Width;
double y = offset.Y - popupSize.Height + DropShadowHeight;
double x = offset.X;
double rotatedComboBoxHeight = targetSize.Width;
if (comboBoxOffsetX + x + rotatedComboBoxHeight + popupSize.Width > screen.Bounds.Width)
{
x -= popupSize.Width;
}
else
{
x += rotatedComboBoxHeight;
}
return new[] { new CustomPopupPlacement(new Point(x, y), PopupPrimaryAxis.Horizontal) };
};

private void ClearOutlinedComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
=> OutlinedComboBox.SelectedItem = null;
public ComboBoxes()
{
InitializeComponent();
DataContext = new ComboBoxesViewModel();
}

private void ClearFilledComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
=> FilledComboBox.SelectedItem = null;

private void ClearOutlinedComboBox_Click(object sender, System.Windows.RoutedEventArgs e)
=> OutlinedComboBox.SelectedItem = null;
}
17 changes: 17 additions & 0 deletions MainDemo.Wpf/Converters/BooleanToDoubleConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Globalization;
using System.Windows.Data;

namespace MaterialDesignDemo.Converters;

public class BooleanToDoubleConverter : MarkupExtension, IValueConverter
{
public double TrueValue { get; set; }
public double FalseValue { get; set; }

public override object ProvideValue(IServiceProvider serviceProvider) => this;

public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is true ? TrueValue : FalseValue;

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException();

}
22 changes: 22 additions & 0 deletions MainDemo.Wpf/VisualTreeUtil.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Windows.Media;

namespace MaterialDesignDemo;

internal static class VisualTreeUtil
{
private static T FindVisualParent<T>(UIElement element) where T : UIElement?
{
UIElement? parent = element;
while (parent != null)
{
if (parent is T correctlyTyped)
{
return correctlyTyped;
}
parent = VisualTreeHelper.GetParent(parent) as UIElement;
}
return default!;
}

internal static T GetElementUnderMouse<T>() where T : UIElement? => FindVisualParent<T>((Mouse.DirectlyOver as UIElement)!);
}
13 changes: 13 additions & 0 deletions MaterialDesignThemes.Wpf/ComboBoxAssist.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,17 @@ public static void SetShowSelectedItem(DependencyObject element, bool value)
public static int GetMaxLength(DependencyObject element) => (int)element.GetValue(MaxLengthProperty);
public static void SetMaxLength(DependencyObject element, int value) => element.SetValue(MaxLengthProperty, value);
#endregion

#region AttachedProperty : CustomPopupPlacementCallback
public static readonly DependencyProperty CustomPopupPlacementCallbackProperty =
DependencyProperty.RegisterAttached(
"CustomPopupPlacementCallback",
typeof(CustomPopupPlacementCallback),
typeof(ComboBoxAssist),
new FrameworkPropertyMetadata(default(CustomPopupPlacementCallback),
FrameworkPropertyMetadataOptions.AffectsRender));

public static void SetCustomPopupPlacementCallback(DependencyObject element, CustomPopupPlacementCallback value) => element.SetValue(CustomPopupPlacementCallbackProperty, value);
public static CustomPopupPlacementCallback GetCustomPopupPlacementCallback(DependencyObject element) => (CustomPopupPlacementCallback) element.GetValue(CustomPopupPlacementCallbackProperty);
#endregion
}
19 changes: 17 additions & 2 deletions MaterialDesignThemes.Wpf/ComboBoxPopup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,16 @@ public PopupDirection OpenDirection
DependencyProperty.Register(nameof(OpenDirection), typeof(PopupDirection),
typeof(ComboBoxPopup), new PropertyMetadata(PopupDirection.None));

public static readonly DependencyProperty CustomPopupPlacementCallbackOverrideProperty =
DependencyProperty.Register(nameof(CustomPopupPlacementCallbackOverride), typeof(CustomPopupPlacementCallback),
typeof(ComboBoxPopup), new PropertyMetadata(default(CustomPopupPlacementCallback)));

public CustomPopupPlacementCallback? CustomPopupPlacementCallbackOverride
{
get => (CustomPopupPlacementCallback?) GetValue(CustomPopupPlacementCallbackOverrideProperty);
set => SetValue(CustomPopupPlacementCallbackOverrideProperty, value);
}

public ComboBoxPopup()
=> CustomPopupPlacementCallback = ComboBoxCustomPopupPlacementCallback;

Expand All @@ -273,6 +283,11 @@ protected override void OnClosed(EventArgs e)
private CustomPopupPlacement[] ComboBoxCustomPopupPlacementCallback(
Size popupSize, Size targetSize, Point offset)
{
if (CustomPopupPlacementCallbackOverride != null)
{
return CustomPopupPlacementCallbackOverride(popupSize, targetSize, offset);
}

var visualAncestry = PlacementTarget.GetVisualAncestry().ToList();

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

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

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

var screen = Screen.FromPoint(locationFromScreen);
var screenWidth = (int)screen.Bounds.Width;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,8 @@
Tag="{DynamicResource MaterialDesignPaper}"
UpVerticalOffset="15"
UseLayoutRounding="{TemplateBinding UseLayoutRounding}"
VerticalOffset="0">
VerticalOffset="0"
CustomPopupPlacementCallbackOverride="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(wpf:ComboBoxAssist.CustomPopupPlacementCallback)}">
<wpf:ComboBoxPopup.Background>
<MultiBinding Converter="{StaticResource FallbackBrushConverter}">
<Binding Path="Background" RelativeSource="{RelativeSource TemplatedParent}" />
Expand Down

0 comments on commit 11b6757

Please sign in to comment.