Microsoft-ui-xaml: Proposal: Multi-select functionality for ComboBox

Created on 4 Feb 2019  路  11Comments  路  Source: microsoft/microsoft-ui-xaml

The WinUI Team has opened a Spec for this feature

Proposal: Multi-select functionality for ComboBox

Summary


This feature would add multi-select capabilities in the dropdown of ComboBox to enable group selection/filtering in space conservative scenarios.

closed combobox that reads "Red, Blue, Gree..." in the preview before being cut off by the dropdown button

combobox with a tick mark to the left of its only selected item

Rationale


Multi-select support for ComboBox is a regular request from enterprise developers and MVPs. Several third-party solutions exist for enabling multi-select functionality on WPF鈥檚 ComboBox. Implementing this feature in parallel to Grouping will delight developers seeking a fully featured ComboBox in UWP.

Functional Requirements

| # | Feature | Priority |
|:-:|:--|:-:|
| 1 | Multiple ComboBox items can be selected. | Must |
| 2 | Multi-select status is indicated in the collapsed ComboBox display. | Must |
| 3 | Drop down does not close when item gets selected. | Must |
| 4 | Select All/Deselect All box at top of list. | Should |
| 5 | Can select/de-select items by group. Dependent on Grouping. | Should |

Important Notes

Open Questions

area-ComboBox feature proposal team-Controls

Most helpful comment

@SavoySchuler I'm happy to share how my multiselect scenario is working, apart from grouping in the UI which is not (waiting on #33).

In IngredientTemplateSelector, you'll notice the properties MinimumSelection and MaximumSelection. My MVVM framework (soon to be open-sourced) has an abstract SelectableNode class that can manage a wide variety of single and multi selection scenarios.

You'll also notice that each group in my scenario consists of either check boxes or radio buttons. It varies by group. For this reason, please provide a way to replace the check box with a radio button (via a data template i guess) either for the whole group or individually.

Local XAML resources:

<Grid.Resources>
    <CollectionViewSource x:Key="ingredients" Source="{Binding ItemData.Ingredients}" IsSourceGrouped="True" ItemsPath="SubItems"/>
    <DataTemplate x:Key="checkbox">
        <CheckBox Content="{Binding DomainItem.Type.Phrase}" IsChecked="{Binding IsSelected, Mode=TwoWay}" Foreground="Black" Padding="10"/>
    </DataTemplate>
    <DataTemplate x:Key="radiobutton">
        <RadioButton Content="{Binding DomainItem.Type.Phrase}" IsChecked="{Binding IsSelected, Mode=TwoWay}" Foreground="Black" Padding="10"
                     GroupName="{Binding Parent}"/>
    </DataTemplate>
    <ui:IngredientTemplateSelector x:Key="ingredientTemplateSelector"
          CheckBoxTemplate="{StaticResource checkbox}" RadioButtonTemplate="{StaticResource radiobutton}"/>
</Grid.Resources>

The combo box:

<lib:ComboBoxAdorner Grid.Column="2" Text="{Binding ItemData.IngredientChanges}" HorizontalAlignment="Left" Margin="15,0,0,0">
    <ComboBox ItemsSource="{Binding Source={StaticResource ingredients}}"
              ItemTemplateSelector="{StaticResource ingredientTemplateSelector}">
        <!-- TODO: Uncomment below once grouping is possible in ComboBox -->
        <!--<ComboBox.GroupStyle>
            <GroupStyle>
                <GroupStyle.HeaderTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding}"/>
                    </DataTemplate>
                </GroupStyle.HeaderTemplate>
            </GroupStyle>
        </ComboBox.GroupStyle>-->
        <ComboBox.ItemContainerStyle>
            <Style TargetType="ComboBoxItem">
                <!-- Increase the "hitability" of the contained checkboxes/radio buttons -->
                <Setter Property="Padding" Value="0"/>
                <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
                <Setter Property="VerticalContentAlignment" Value="Stretch"/>
            </Style>
        </ComboBox.ItemContainerStyle>
    </ComboBox>
</lib:ComboBoxAdorner>

The template selector:

public class IngredientTemplateSelector : DataTemplateSelector
{
    public DataTemplate RadioButtonTemplate { get; set; }
    public DataTemplate CheckBoxTemplate { get; set; }

    protected override DataTemplate SelectTemplateCore( object item, DependencyObject container )
    {
        if ( item == null ) return null;

        var ingredientPM = (OptionPM)item;
        var ingredientsListPM = ingredientPM.Parent;
        return ingredientsListPM.RootData.MinimumSelection == 1 && ingredientsListPM.RootData.MaximumSelection == 1 ?
            RadioButtonTemplate : CheckBoxTemplate;
    }
}

The combo box adorner:

[TemplateVisualState( Name = "Normal", GroupName = "CommonStates" )]
[TemplateVisualState( Name = "Disabled", GroupName = "CommonStates" )]
public class ComboBoxAdorner : ContentControl
{
    public ComboBoxAdorner()
    {
        DefaultStyleKey = typeof( ComboBoxAdorner );
        IsEnabledChanged += this_IsEnabledChanged;
    }

    #region 'Text' Identifier

    public string Text
    {
        get { return (string)GetValue( TextProperty ); }
        set { SetValue( TextProperty, value ); }
    }

    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(
            "Text", typeof( string ), typeof( ComboBoxAdorner ), null
        );

    #endregion 'Text' Identifier

    #region Event Handlers

    protected override void OnApplyTemplate()
    {
        VisualStateManager.GoToState( this, IsEnabled ? "Normal" : "Disabled", false );
        base.OnApplyTemplate();
    }

    void this_IsEnabledChanged( object sender, DependencyPropertyChangedEventArgs e )
    {
        VisualStateManager.GoToState( this, (bool)e.NewValue ? "Normal" : "Disabled", true );
    }

    protected override void OnContentChanged( object oldContent, object newContent )
    {
        if ( oldContent != null ) {
            var comboBox = (ComboBox)oldContent;
            comboBox.SelectionChanged -= comboBox_SelectionChanged;

            comboBox.ClearValue( ComboBox.VerticalAlignmentProperty );
            comboBox.ClearValue( ComboBox.HorizontalAlignmentProperty );
            if ( comboBox.HorizontalAlignment != originalHorizontalContentAlignment )
                comboBox.HorizontalAlignment = originalHorizontalContentAlignment;
            if ( comboBox.VerticalAlignment != originalVerticalContentAlignment )
                comboBox.VerticalAlignment = originalVerticalContentAlignment;
        }

        if ( newContent != null ) {
            var comboBox = newContent as ComboBox;
            if ( comboBox == null )
                throw new InvalidOperationException( "ComboBoxAdorner must contain a ComboBox" );

            originalHorizontalContentAlignment = comboBox.HorizontalAlignment;
            originalVerticalContentAlignment = comboBox.VerticalAlignment;
            comboBox.HorizontalAlignment = HorizontalAlignment.Stretch;
            comboBox.VerticalAlignment = VerticalAlignment.Stretch;

            comboBox.SelectionChanged += comboBox_SelectionChanged;
        }

        base.OnContentChanged( oldContent, newContent );
    }
    HorizontalAlignment originalHorizontalContentAlignment;
    VerticalAlignment originalVerticalContentAlignment;

    // Prevent ComboBox selection, which would be meaningless.
    void comboBox_SelectionChanged( object sender, SelectionChangedEventArgs e )
    {
        if ( e.AddedItems.Count > 0 )
            ( (ComboBox)sender ).SelectedItem = null;
    }

    #endregion Event Handlers
}

The combo box adorner's XAML:

<Style TargetType="local:ComboBoxAdorner">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="local:ComboBoxAdorner">
                <Grid>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CommonStates">
                            <VisualState x:Name="Normal" />
                            <VisualState x:Name="Disabled">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="textBlock" Storyboard.TargetProperty="Foreground">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ComboBoxDisabledForegroundThemeBrush}" />
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                    <ContentPresenter Name="comboBoxPresenter"/>
                    <TextBlock Name="textBlock" Text="{TemplateBinding Text}" TextWrapping="Wrap"
                               Foreground="{ThemeResource ComboBoxForeground}" IsHitTestVisible="False" Margin="12,5,32,7">
                        <!-- Prevent issues while selecting/deselecting ingredients (growing/shrinking of popup
                             and jumping to middle of list) -->
                        <TextBlock.Visibility>
                            <Binding Path="Content.IsDropDownOpen" ElementName="comboBoxPresenter">
                                <Binding.Converter>
                                    <local:BooleanConverter TrueValue="Collapsed" FalseValue="Visible"/>
                                </Binding.Converter>
                            </Binding>
                        </TextBlock.Visibility>
                    </TextBlock>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

All 11 comments

Should the entries have a complete Checkbox as in the Checkbox Control, or a simplified tick mark as with the Menu and Context Menu controls?

@ChainReactive Would you mind sharing how you achieved your solution?

@SavoySchuler I'm happy to share how my multiselect scenario is working, apart from grouping in the UI which is not (waiting on #33).

In IngredientTemplateSelector, you'll notice the properties MinimumSelection and MaximumSelection. My MVVM framework (soon to be open-sourced) has an abstract SelectableNode class that can manage a wide variety of single and multi selection scenarios.

You'll also notice that each group in my scenario consists of either check boxes or radio buttons. It varies by group. For this reason, please provide a way to replace the check box with a radio button (via a data template i guess) either for the whole group or individually.

Local XAML resources:

<Grid.Resources>
    <CollectionViewSource x:Key="ingredients" Source="{Binding ItemData.Ingredients}" IsSourceGrouped="True" ItemsPath="SubItems"/>
    <DataTemplate x:Key="checkbox">
        <CheckBox Content="{Binding DomainItem.Type.Phrase}" IsChecked="{Binding IsSelected, Mode=TwoWay}" Foreground="Black" Padding="10"/>
    </DataTemplate>
    <DataTemplate x:Key="radiobutton">
        <RadioButton Content="{Binding DomainItem.Type.Phrase}" IsChecked="{Binding IsSelected, Mode=TwoWay}" Foreground="Black" Padding="10"
                     GroupName="{Binding Parent}"/>
    </DataTemplate>
    <ui:IngredientTemplateSelector x:Key="ingredientTemplateSelector"
          CheckBoxTemplate="{StaticResource checkbox}" RadioButtonTemplate="{StaticResource radiobutton}"/>
</Grid.Resources>

The combo box:

<lib:ComboBoxAdorner Grid.Column="2" Text="{Binding ItemData.IngredientChanges}" HorizontalAlignment="Left" Margin="15,0,0,0">
    <ComboBox ItemsSource="{Binding Source={StaticResource ingredients}}"
              ItemTemplateSelector="{StaticResource ingredientTemplateSelector}">
        <!-- TODO: Uncomment below once grouping is possible in ComboBox -->
        <!--<ComboBox.GroupStyle>
            <GroupStyle>
                <GroupStyle.HeaderTemplate>
                    <DataTemplate>
                        <TextBlock Text="{Binding}"/>
                    </DataTemplate>
                </GroupStyle.HeaderTemplate>
            </GroupStyle>
        </ComboBox.GroupStyle>-->
        <ComboBox.ItemContainerStyle>
            <Style TargetType="ComboBoxItem">
                <!-- Increase the "hitability" of the contained checkboxes/radio buttons -->
                <Setter Property="Padding" Value="0"/>
                <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
                <Setter Property="VerticalContentAlignment" Value="Stretch"/>
            </Style>
        </ComboBox.ItemContainerStyle>
    </ComboBox>
</lib:ComboBoxAdorner>

The template selector:

public class IngredientTemplateSelector : DataTemplateSelector
{
    public DataTemplate RadioButtonTemplate { get; set; }
    public DataTemplate CheckBoxTemplate { get; set; }

    protected override DataTemplate SelectTemplateCore( object item, DependencyObject container )
    {
        if ( item == null ) return null;

        var ingredientPM = (OptionPM)item;
        var ingredientsListPM = ingredientPM.Parent;
        return ingredientsListPM.RootData.MinimumSelection == 1 && ingredientsListPM.RootData.MaximumSelection == 1 ?
            RadioButtonTemplate : CheckBoxTemplate;
    }
}

The combo box adorner:

[TemplateVisualState( Name = "Normal", GroupName = "CommonStates" )]
[TemplateVisualState( Name = "Disabled", GroupName = "CommonStates" )]
public class ComboBoxAdorner : ContentControl
{
    public ComboBoxAdorner()
    {
        DefaultStyleKey = typeof( ComboBoxAdorner );
        IsEnabledChanged += this_IsEnabledChanged;
    }

    #region 'Text' Identifier

    public string Text
    {
        get { return (string)GetValue( TextProperty ); }
        set { SetValue( TextProperty, value ); }
    }

    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(
            "Text", typeof( string ), typeof( ComboBoxAdorner ), null
        );

    #endregion 'Text' Identifier

    #region Event Handlers

    protected override void OnApplyTemplate()
    {
        VisualStateManager.GoToState( this, IsEnabled ? "Normal" : "Disabled", false );
        base.OnApplyTemplate();
    }

    void this_IsEnabledChanged( object sender, DependencyPropertyChangedEventArgs e )
    {
        VisualStateManager.GoToState( this, (bool)e.NewValue ? "Normal" : "Disabled", true );
    }

    protected override void OnContentChanged( object oldContent, object newContent )
    {
        if ( oldContent != null ) {
            var comboBox = (ComboBox)oldContent;
            comboBox.SelectionChanged -= comboBox_SelectionChanged;

            comboBox.ClearValue( ComboBox.VerticalAlignmentProperty );
            comboBox.ClearValue( ComboBox.HorizontalAlignmentProperty );
            if ( comboBox.HorizontalAlignment != originalHorizontalContentAlignment )
                comboBox.HorizontalAlignment = originalHorizontalContentAlignment;
            if ( comboBox.VerticalAlignment != originalVerticalContentAlignment )
                comboBox.VerticalAlignment = originalVerticalContentAlignment;
        }

        if ( newContent != null ) {
            var comboBox = newContent as ComboBox;
            if ( comboBox == null )
                throw new InvalidOperationException( "ComboBoxAdorner must contain a ComboBox" );

            originalHorizontalContentAlignment = comboBox.HorizontalAlignment;
            originalVerticalContentAlignment = comboBox.VerticalAlignment;
            comboBox.HorizontalAlignment = HorizontalAlignment.Stretch;
            comboBox.VerticalAlignment = VerticalAlignment.Stretch;

            comboBox.SelectionChanged += comboBox_SelectionChanged;
        }

        base.OnContentChanged( oldContent, newContent );
    }
    HorizontalAlignment originalHorizontalContentAlignment;
    VerticalAlignment originalVerticalContentAlignment;

    // Prevent ComboBox selection, which would be meaningless.
    void comboBox_SelectionChanged( object sender, SelectionChangedEventArgs e )
    {
        if ( e.AddedItems.Count > 0 )
            ( (ComboBox)sender ).SelectedItem = null;
    }

    #endregion Event Handlers
}

The combo box adorner's XAML:

<Style TargetType="local:ComboBoxAdorner">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="local:ComboBoxAdorner">
                <Grid>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CommonStates">
                            <VisualState x:Name="Normal" />
                            <VisualState x:Name="Disabled">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="textBlock" Storyboard.TargetProperty="Foreground">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ComboBoxDisabledForegroundThemeBrush}" />
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                    <ContentPresenter Name="comboBoxPresenter"/>
                    <TextBlock Name="textBlock" Text="{TemplateBinding Text}" TextWrapping="Wrap"
                               Foreground="{ThemeResource ComboBoxForeground}" IsHitTestVisible="False" Margin="12,5,32,7">
                        <!-- Prevent issues while selecting/deselecting ingredients (growing/shrinking of popup
                             and jumping to middle of list) -->
                        <TextBlock.Visibility>
                            <Binding Path="Content.IsDropDownOpen" ElementName="comboBoxPresenter">
                                <Binding.Converter>
                                    <local:BooleanConverter TrueValue="Collapsed" FalseValue="Visible"/>
                                </Binding.Converter>
                            </Binding>
                        </TextBlock.Visibility>
                    </TextBlock>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

What does the closed state look like? Could this be a DropDownButton with a ListView?

@MikeHillberg I added an image of what I was imagining to the summary. Please let me iknow what you think!

@mdtauk I believe you are correct, I have updated it.

@ChainReactive, this is awesome! Thank you for sharing this with us! It's clear that we have room to make this more easily achievable and your work is an excellent starting point for figuring out how.

@mdtauk and @ChainReactive, thank you both for also helping to getting this feature started!

It has been approved and I have opened up a spec for it here.

As noted on Grouping Support for ComboBox, we would be eager to see you involved in our spec writing where you can tell us specifics about how you would like this feature implemented. @niels9001, you may also be interested since this feature development will be cooperative with the Grouping Support for ComboBox you pitched.

It may be several months before we are able to fully commit PM & Dev resources to this feature, but your early engagement will still help jumpstart both of these developments. Please let me know if you have any questions. I have added our default spec template and will jump into contribute when I can!

What does the closed state look like?

@SavoySchuler The image in the summary looks fine as a default, but it won't suffice in my scenario. I expect I'll be able to continue binding the Text property to my view-model.

@SavoySchuler It's been a while since this thread was opened. I see that grouping for the ComboBox would require WinUI 3.0.

Are there any updates on this topic? Anything we can do to speed up the progress?

Brought this up in the questions of today's session and wondered if it had already been requested. Is this something that we might see come in WinUI 3 or potentially post RTM @SavoySchuler ?

I've previously built a custom control which provides ComboBox-like support for multi select taking advantage of the UWP ListView control but it would be awesome to see this done natively instead.

Was this page helpful?
0 / 5 - 0 ratings