microsoft / microsoft-ui-xaml

Windows UI Library: the latest Windows 10 native controls and Fluent styles for your applications
MIT License
6.36k stars 678 forks source link

Proposal: Multi-select functionality for ComboBox #263

Open SavoySchuler opened 5 years ago

SavoySchuler commented 5 years ago

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’s 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

mdtauk commented 5 years ago

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?

SavoySchuler commented 5 years ago

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

HappyNomad commented 5 years ago

@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>
MikeHillberg commented 5 years ago

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

SavoySchuler commented 5 years ago

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

SavoySchuler commented 5 years ago

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

SavoySchuler commented 5 years ago

@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.

SavoySchuler commented 5 years ago

@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!

HappyNomad commented 5 years ago

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.

niels9001 commented 4 years ago

@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?

jamesmcroft commented 3 years ago

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.

jamesmcroft commented 3 years ago

Update on this since the last comment made, I decided to publish my ComboBox-like control that supports both single and multiple selection modes in the interim while this functionality is not available

https://made-apps.github.io/MADE.NET/articles/features/ui-controls-dropdownlist.html

KWodarczyk commented 1 week ago

Maybe it would be useful to add a button at the bottom of the list e.g. "Apply" so that user can apply changes while combo box is open ? This way we also save some space in the app as the button is only needed while selecting from combobox. JIRA does something like this:

Image