如何使 WPF 组合框具有 XAML 中最宽元素的宽度?

我知道如何在代码中做到这一点,但这可以在 XAML 中完成吗?

视窗1.xaml:

<Window x:Class="WpfApplication1.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Grid>
<ComboBox Name="ComboBox1" HorizontalAlignment="Left" VerticalAlignment="Top">
<ComboBoxItem>ComboBoxItem1</ComboBoxItem>
<ComboBoxItem>ComboBoxItem2</ComboBoxItem>
</ComboBox>
</Grid>
</Window>

视窗1.xaml.cs:

using System.Windows;
using System.Windows.Controls;


namespace WpfApplication1
{
public partial class Window1 : Window
{
public Window1()
{
InitializeComponent();
double width = 0;
foreach (ComboBoxItem item in ComboBox1.Items)
{
item.Measure(new Size(
double.PositiveInfinity, double.PositiveInfinity));
if (item.DesiredSize.Width > width)
width = item.DesiredSize.Width;
}
ComboBox1.Measure(new Size(
double.PositiveInfinity, double.PositiveInfinity));
ComboBox1.Width = ComboBox1.DesiredSize.Width + width;
}
}
}
61058 次浏览

Yeah, this one is a bit nasty.

What I've done in the past is to add into the ControlTemplate a hidden listbox (with its itemscontainerpanel set to a grid) showing every item at the same time but with their visibility set to hidden.

I'd be pleased to hear of any better ideas that don't rely on horrible code-behind or your view having to understand that it needs to use a different control to provide the width to support the visuals (yuck!).

This can't be in XAML without either:

  • Creating a hidden control (Alan Hunford's answer)
  • Changing the ControlTemplate drastically. Even in this case, a hidden version of an ItemsPresenter may need to be created.

The reason for this is that the default ComboBox ControlTemplates that I've come across (Aero, Luna, etc.) all nest the ItemsPresenter in a Popup. This means that the layout of these items is deferred until they are actually made visible.

An easy way to test this is to modify the default ControlTemplate to bind the MinWidth of the outermost container (it's a Grid for both Aero and Luna) to the ActualWidth of PART_Popup. You'll be able to have the ComboBox automatically synchronize it's width when you click the drop button, but not before.

So unless you can force a Measure operation in the layout system (which you can do by adding a second control), I don't think it can be done.

As always, I'm open to an short, elegant solution -- but in this case a code-behind or dual-control/ControlTemplate hacks are the only solutions I have seen.

Put an listbox containing the same content behind the dropbox. Then enforce correct height with some binding like this:

<Grid>
<ListBox x:Name="listBox" Height="{Binding ElementName=dropBox, Path=DesiredSize.Height}" />
<ComboBox x:Name="dropBox" />
</Grid>

I ended up with a "good enough" solution to this problem being to make the combo box never shrink below the largest size it held, similar to the old WinForms AutoSizeMode=GrowOnly.

The way I did this was with a custom value converter:

public class GrowConverter : IValueConverter
{
public double Minimum
{
get;
set;
}


public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var dvalue = (double)value;
if (dvalue > Minimum)
Minimum = dvalue;
else if (dvalue < Minimum)
dvalue = Minimum;
return dvalue;
}


public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}

Then I configure the combo box in XAML like so:

 <Whatever>
<Whatever.Resources>
<my:GrowConverter x:Key="grow" />
</Whatever.Resources>
...
<ComboBox MinWidth="{Binding ActualWidth,RelativeSource={RelativeSource Self},Converter={StaticResource grow}}" />
</Whatever>

Note that with this you need a separate instance of the GrowConverter for each combo box, unless of course you want a set of them to size together, similar to the Grid's SharedSizeScope feature.

You can't do it directly in Xaml but you can use this Attached Behavior. (The Width will be visible in the Designer)

<ComboBox behaviors:ComboBoxWidthFromItemsBehavior.ComboBoxWidthFromItems="True">
<ComboBoxItem Content="Short"/>
<ComboBoxItem Content="Medium Long"/>
<ComboBoxItem Content="Min"/>
</ComboBox>

The Attached Behavior ComboBoxWidthFromItemsProperty

public static class ComboBoxWidthFromItemsBehavior
{
public static readonly DependencyProperty ComboBoxWidthFromItemsProperty =
DependencyProperty.RegisterAttached
(
"ComboBoxWidthFromItems",
typeof(bool),
typeof(ComboBoxWidthFromItemsBehavior),
new UIPropertyMetadata(false, OnComboBoxWidthFromItemsPropertyChanged)
);
public static bool GetComboBoxWidthFromItems(DependencyObject obj)
{
return (bool)obj.GetValue(ComboBoxWidthFromItemsProperty);
}
public static void SetComboBoxWidthFromItems(DependencyObject obj, bool value)
{
obj.SetValue(ComboBoxWidthFromItemsProperty, value);
}
private static void OnComboBoxWidthFromItemsPropertyChanged(DependencyObject dpo,
DependencyPropertyChangedEventArgs e)
{
ComboBox comboBox = dpo as ComboBox;
if (comboBox != null)
{
if ((bool)e.NewValue == true)
{
comboBox.Loaded += OnComboBoxLoaded;
}
else
{
comboBox.Loaded -= OnComboBoxLoaded;
}
}
}
private static void OnComboBoxLoaded(object sender, RoutedEventArgs e)
{
ComboBox comboBox = sender as ComboBox;
Action action = () => { comboBox.SetWidthFromItems(); };
comboBox.Dispatcher.BeginInvoke(action, DispatcherPriority.ContextIdle);
}
}

What it does is that it calls an extension method for ComboBox called SetWidthFromItems which (invisibly) expands and collapses itself and then calculates the Width based on the generated ComboBoxItems. (IExpandCollapseProvider requires a reference to UIAutomationProvider.dll)

Then extension method SetWidthFromItems

public static class ComboBoxExtensionMethods
{
public static void SetWidthFromItems(this ComboBox comboBox)
{
double comboBoxWidth = 19;// comboBox.DesiredSize.Width;


// Create the peer and provider to expand the comboBox in code behind.
ComboBoxAutomationPeer peer = new ComboBoxAutomationPeer(comboBox);
IExpandCollapseProvider provider = (IExpandCollapseProvider)peer.GetPattern(PatternInterface.ExpandCollapse);
EventHandler eventHandler = null;
eventHandler = new EventHandler(delegate
{
if (comboBox.IsDropDownOpen &&
comboBox.ItemContainerGenerator.Status == GeneratorStatus.ContainersGenerated)
{
double width = 0;
foreach (var item in comboBox.Items)
{
ComboBoxItem comboBoxItem = comboBox.ItemContainerGenerator.ContainerFromItem(item) as ComboBoxItem;
comboBoxItem.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
if (comboBoxItem.DesiredSize.Width > width)
{
width = comboBoxItem.DesiredSize.Width;
}
}
comboBox.Width = comboBoxWidth + width;
// Remove the event handler.
comboBox.ItemContainerGenerator.StatusChanged -= eventHandler;
comboBox.DropDownOpened -= eventHandler;
provider.Collapse();
}
});
comboBox.ItemContainerGenerator.StatusChanged += eventHandler;
comboBox.DropDownOpened += eventHandler;
// Expand the comboBox to generate all its ComboBoxItem's.
provider.Expand();
}
}

This extension method also provides to ability to call

comboBox.SetWidthFromItems();

in code behind (e.g in the ComboBox.Loaded event)

In my case a much simpler way seemed to do the trick, I just used an extra stackPanel to wrap the combobox.

<StackPanel Grid.Row="1" Orientation="Horizontal">
<ComboBox ItemsSource="{Binding ExecutionTimesModeList}" Width="Auto"
SelectedValuePath="Item" DisplayMemberPath="FriendlyName"
SelectedValue="{Binding Model.SelectedExecutionTimesMode}" />
</StackPanel>

(worked in visual studio 2008)

Based on the other answers above, here's my version:

<Grid HorizontalAlignment="Left">
<ItemsControl ItemsSource="{Binding EnumValues}" Height="0" Margin="15,0"/>
<ComboBox ItemsSource="{Binding EnumValues}" />
</Grid>

HorizontalAlignment="Left" stops the controls using the full width of the containing control. Height="0" hides the items control.
Margin="15,0" allows for additional chrome around combo-box items (not chrome agnostic I'm afraid).

A follow up to Maleak's answer: I liked that implementation so much, I wrote an actual Behavior for it. Obviously you'll need the Blend SDK so you can reference System.Windows.Interactivity.

XAML:

    <ComboBox ItemsSource="{Binding ListOfStuff}">
<i:Interaction.Behaviors>
<local:ComboBoxWidthBehavior />
</i:Interaction.Behaviors>
</ComboBox>

Code:

using System;
using System.Windows;
using System.Windows.Automation.Peers;
using System.Windows.Automation.Provider;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Interactivity;


namespace MyLibrary
{
public class ComboBoxWidthBehavior : Behavior<ComboBox>
{
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Loaded += OnLoaded;
}


protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.Loaded -= OnLoaded;
}


private void OnLoaded(object sender, RoutedEventArgs e)
{
var desiredWidth = AssociatedObject.DesiredSize.Width;


// Create the peer and provider to expand the comboBox in code behind.
var peer = new ComboBoxAutomationPeer(AssociatedObject);
var provider = peer.GetPattern(PatternInterface.ExpandCollapse) as IExpandCollapseProvider;
if (provider == null)
return;


EventHandler[] handler = {null};    // array usage prevents access to modified closure
handler[0] = new EventHandler(delegate
{
if (!AssociatedObject.IsDropDownOpen || AssociatedObject.ItemContainerGenerator.Status != GeneratorStatus.ContainersGenerated)
return;


double largestWidth = 0;
foreach (var item in AssociatedObject.Items)
{
var comboBoxItem = AssociatedObject.ItemContainerGenerator.ContainerFromItem(item) as ComboBoxItem;
if (comboBoxItem == null)
continue;


comboBoxItem.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
if (comboBoxItem.DesiredSize.Width > largestWidth)
largestWidth = comboBoxItem.DesiredSize.Width;
}


AssociatedObject.Width = desiredWidth + largestWidth;


// Remove the event handler.
AssociatedObject.ItemContainerGenerator.StatusChanged -= handler[0];
AssociatedObject.DropDownOpened -= handler[0];
provider.Collapse();
});


AssociatedObject.ItemContainerGenerator.StatusChanged += handler[0];
AssociatedObject.DropDownOpened += handler[0];


// Expand the comboBox to generate all its ComboBoxItem's.
provider.Expand();
}
}
}

I was looking for the answer myself, when I came across the UpdateLayout() method that every UIElement has.

It's very simple now, thankfully!

Just call ComboBox1.Updatelayout(); after you set or modify the ItemSource.

Alun Harford's approach, in practice :

<Grid>


<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>


<!-- hidden listbox that has all the items in one grid -->
<ListBox ItemsSource="{Binding Items, ElementName=uiComboBox, Mode=OneWay}" Height="10" VerticalAlignment="Top" Visibility="Hidden">
<ListBox.ItemsPanel><ItemsPanelTemplate><Grid/></ItemsPanelTemplate></ListBox.ItemsPanel>
</ListBox>


<ComboBox VerticalAlignment="Top" SelectedIndex="0" x:Name="uiComboBox">
<ComboBoxItem>foo</ComboBoxItem>
<ComboBoxItem>bar</ComboBoxItem>
<ComboBoxItem>fiuafiouhoiruhslkfhalsjfhalhflasdkf</ComboBoxItem>
</ComboBox>


</Grid>

This keeps the width to the widest element but only after opening the combo box once.

<ComboBox ItemsSource="{Binding ComboBoxItems}" Grid.IsSharedSizeScope="True" HorizontalAlignment="Left">
<ComboBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition SharedSizeGroup="sharedSizeGroup"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding}"/>
</Grid>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>

An alternative solution to the top answer is to Measure the Popup itself rather than measuring all the items. Giving slightly simpler SetWidthFromItems() implementation:

private static void SetWidthFromItems(this ComboBox comboBox)
{
if (comboBox.Template.FindName("PART_Popup", comboBox) is Popup popup
&& popup.Child is FrameworkElement popupContent)
{
popupContent.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
// suggested in comments, original answer has a static value 19.0
var emptySize = SystemParameters.VerticalScrollBarWidth + comboBox.Padding.Left + comboBox.Padding.Right;
comboBox.Width = emptySize + popupContent.DesiredSize.Width;
}
}

works on disabled ComboBoxes as well.

I wanted it to only resize to the max element while the dropdown is open, and otherwise fit to the selected value. Here's the code for that:

Based in part on Frederik's answer (which didn't actually work for me)

public static class ComboBoxAutoWidthBehavior {
public static readonly DependencyProperty ComboBoxAutoWidthProperty =
DependencyProperty.RegisterAttached(
"ComboBoxAutoWidth",
typeof(bool),
typeof(ComboBoxAutoWidthBehavior),
new UIPropertyMetadata(false, OnComboBoxAutoWidthPropertyChanged)
);


public static bool GetComboBoxAutoWidth(DependencyObject obj) {
return (bool) obj.GetValue(ComboBoxAutoWidthProperty);
}


public static void SetComboBoxAutoWidth(DependencyObject obj, bool value) {
obj.SetValue(ComboBoxAutoWidthProperty, value);
}


private static void OnComboBoxAutoWidthPropertyChanged(DependencyObject dpo, DependencyPropertyChangedEventArgs e) {
if(dpo is ComboBox comboBox) {
if((bool) e.NewValue) {
comboBox.Loaded += OnComboBoxLoaded;
comboBox.DropDownOpened += OnComboBoxOpened;
comboBox.DropDownClosed += OnComboBoxClosed;
} else {
comboBox.Loaded -= OnComboBoxLoaded;
comboBox.DropDownOpened -= OnComboBoxOpened;
comboBox.DropDownClosed -= OnComboBoxClosed;
}
}
}


private static void OnComboBoxLoaded(object sender, EventArgs eventArgs) {
ComboBox comboBox = (ComboBox) sender;
comboBox.SetMaxWidthFromItems();
}


private static void OnComboBoxOpened(object sender, EventArgs eventArgs) {
ComboBox comboBox = (ComboBox) sender;
comboBox.Width = comboBox.MaxWidth;
}


private static void OnComboBoxClosed(object sender, EventArgs eventArgs) => ((ComboBox) sender).Width = double.NaN;
}


public static class ComboBoxExtensionMethods {
public static void SetMaxWidthFromItems(this ComboBox combo) {
double idealWidth = combo.MinWidth;
string longestItem = combo.Items.Cast<object>().Select(x => x.ToString()).Max(x => (x?.Length, x)).x;
if(longestItem != null && longestItem.Length >= 0) {
string tmpTxt = combo.Text;
combo.Text = longestItem;
Thickness tmpMarg = combo.Margin;
combo.Margin = new Thickness(0);
combo.UpdateLayout();


combo.Width = double.NaN;
combo.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));


idealWidth = Math.Max(idealWidth, combo.DesiredSize.Width);


combo.Text = tmpTxt;
combo.Margin = tmpMarg;
}


combo.MaxWidth = idealWidth;
}
}

And you enable it like this:

<ComboBox behaviours:ComboBoxAutoWidthBehavior.ComboBoxAutoWidth="True" />

You could also just set Width directly instead of MaxWidth, and then remove the DropDownOpened and Closed parts if you want it to behave like the other anwsers.