سؤال

I have multiple expanders, and I was looking for a way to collapse all others the expanders when one of them is expanded. And I found this solution here

XAML:

<StackPanel Name="StackPanel1">
    <StackPanel.Resources>
        <local:ExpanderToBooleanConverter x:Key="ExpanderToBooleanConverter" />
    </StackPanel.Resources>
    <Expander Header="Expander 1"
        IsExpanded="{Binding SelectedExpander, Mode=TwoWay, Converter={StaticResource ExpanderToBooleanConverter}, ConverterParameter=1}">
        <TextBlock>Expander 1</TextBlock>
    </Expander>
    <Expander Header="Expander 2"
        IsExpanded="{Binding SelectedExpander, Mode=TwoWay, Converter={StaticResource ExpanderToBooleanConverter}, ConverterParameter=2}">
        <TextBlock>Expander 2</TextBlock>
    </Expander>
    <Expander Header="Expander 3"
        IsExpanded="{Binding SelectedExpander, Mode=TwoWay, Converter={StaticResource ExpanderToBooleanConverter}, ConverterParameter=3}">
        <TextBlock>Expander 3</TextBlock>
    </Expander>
    <Expander Header="Expander 4"
        IsExpanded="{Binding SelectedExpander, Mode=TwoWay, Converter={StaticResource ExpanderToBooleanConverter}, ConverterParameter=4}">
        <TextBlock>Expander 4</TextBlock>
    </Expander>
</StackPanel>

Converter:

public class ExpanderToBooleanConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return (value == parameter);

        // I tried thoses too :
        return value != null && (value.ToString() == parameter.ToString());
        return value != null && (value.ToString().Equals(parameter.ToString()));
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return System.Convert.ToBoolean(value) ? parameter : null;
    }
}

ViewModel:

public class ExpanderListViewModel : INotifyPropertyChanged
{
    private Object _selectedExpander;

    public Object SelectedExpander
    {
        get { return _selectedExpander; } 
        set
        {
            if (_selectedExpander == value)
            {
                return;
            }

            _selectedExpander = value;
            OnPropertyChanged("SelectedExpander");
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(string propertyName)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Initialization

var viewModel = new ExpanderListViewModel();
StackPanel1.DataContext = viewModel;
viewModel.SelectedExpander = 1;

// I tried this also
viewModel.SelectedExpander = "1";

It's working fine, but now I want to expand one of the expanders at the application startup !

I already tried to put the values (1, 2 or 3) in SelectedExpander property, but none of expanders get expanded by default !

How can I add this possibility to my expanders ?

هل كانت مفيدة؟

المحلول

Consider what would happen if you called UpdateSource on Expander 2 while Expander 1 is selected:

  • ConvertBack is called for Expander 2 with its current IsExpanded value (false), and returns null.
  • SelectedExpander is updated to null.
  • Convert is called for all other expanders, because SelectedExpander changed, causing all the other IsExpanded values to be set to false as well.

This isn't the correct behavior, of course. So the solution is dependent on the source never being updated except for when a user actually toggles an expander.

Thus, I suspect the problem is that the initialization of the controls is somehow triggering a source update. Even if Expander 1 was correctly initialized as expanded, it would be reset when the bindings were refreshed on any of the other expanders.

To make ConvertBack correct, it would need to be aware of the other expanders: It should only return null if all of them are collapsed. I don't see a clean way of handling this from within a converter, though. Perhaps the best solution then would be to use a one-way binding (no ConvertBack) and handle the Expanded and Collapsed events this way or similar (where _expanders is a list of all of the expander controls):

private void OnExpanderIsExpandedChanged(object sender, RoutedEventArgs e) {
    var selectedExpander = _expanders.FirstOrDefault(e => e.IsExpanded);
    if (selectedExpander == null) {
        viewmodel.SelectedExpander = null;
    } else {
        viewmodel.SelectedExpander = selectedExpander.Tag;
    }
}

In this case I'm using Tag for the identifier used in the viewmodel.

EDIT:

To solve it in a more "MVVM" way, you could have a collection of viewmodels for each expander, with an individual property to bind IsExpanded to:

public class ExpanderViewModel {
    public bool IsSelected { get; set; }
    // todo INotifyPropertyChanged etc.
}

Store the collection in ExpanderListViewModel and add PropertyChanged handlers for each one at initialization:

// in ExpanderListViewModel
foreach (var expanderViewModel in Expanders) {
    expanderViewModel.PropertyChanged += Expander_PropertyChanged;
}

...

private void Expander_PropertyChanged(object sender, PropertyChangedEventArgs e) {
    var thisExpander = (ExpanderViewModel)sender;
    if (e.PropertyName == "IsSelected") {
        if (thisExpander.IsSelected) {
            foreach (var otherExpander in Expanders.Except(new[] {thisExpander})) {
                otherExpander.IsSelected = false;
            }
        }
    }
}

Then bind each expander to a different item of the Expanders collection:

<Expander Header="Expander 1" IsExpanded="{Binding Expanders[0].IsSelected}">
    <TextBlock>Expander 1</TextBlock>
</Expander>
<Expander Header="Expander 2" IsExpanded="{Binding Expanders[1].IsSelected}">
    <TextBlock>Expander 2</TextBlock>
</Expander>

(You may also want to look into defining a custom ItemsControl to dynamically generate the Expanders based on the collection.)

In this case the SelectedExpander property would no longer be needed, but it could be implemented this way:

private ExpanderViewModel _selectedExpander;
public ExpanderViewModel SelectedExpander
{
    get { return _selectedExpander; } 
    set
    {
        if (_selectedExpander == value)
        {
            return;
        }

        // deselect old expander
        if (_selectedExpander != null) {
           _selectedExpander.IsSelected = false;
        }

        _selectedExpander = value;

        // select new expander
        if (_selectedExpander != null) {
            _selectedExpander.IsSelected = true;
        }

        OnPropertyChanged("SelectedExpander");
    }
}

And update the above PropertyChanged handler as:

if (thisExpander.IsSelected) {
    ...
    SelectedExpander = thisExpander;
} else {
    SelectedExpander = null;
}

So now these two lines would be equivalent ways of initializing the first expander:

viewModel.SelectedExpander = viewModel.Expanders[0];
viewModel.Expanders[0].IsSelected = true;

نصائح أخرى

Change the Convert method (given here) content as follows

 if (value == null)
     return false;
 return (value.ToString() == parameter.ToString());

Previous content not working because of object comparison with == operator.

I created an WPF project with just your code, having the StackPanel as the content of the MainWindow and invoking your Initialization code after calling InitializeComponent() in MainWindow() and works like a charm by simply removing

return (value == parameter);

from your ExpanderToBooleanConverter.Convert. Actually @Boopesh answer works too. Even if you do

return ((string)value == (string)parameter);

it works, but in that case only string values are supported for SelectedExpander.

I'd suggest you to try again those other returns in your Convert and if it doesn't work, your problem may be in your initialization code. It is possible that you are setting SelectedExpander before the components have been properly initialized.

I have wrote an example code which demonstrate how to achive what you want.

<ItemsControl ItemsSource="{Binding Path=Items}">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <StackPanel />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <RadioButton GroupName="group">
                    <RadioButton.Template>
                        <ControlTemplate>
                            <Expander Header="{Binding Path=Header}" Content="{Binding Path=Content}" 
                                      IsExpanded="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=IsChecked}" />
                        </ControlTemplate>
                    </RadioButton.Template>
                </RadioButton>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>

The model looks like so:

public class Model
{
    public string Header { get; set; }
    public string Content { get; set; }
}

And the ViewModel expose the model to the view:

public IList<Model> Items
    {
        get
        {
            IList<Model> items = new List<Model>();
            items.Add(new Model() { Header = "Header 1", Content = "Header 1 content" });
            items.Add(new Model() { Header = "Header 2", Content = "Header 2 content" });
            items.Add(new Model() { Header = "Header 3", Content = "Header 3 content" });

            return items;
        }
    }

If you dont wont to create a view model (Maybe this is a static) you can use the x:Array markup extension.

you can find example here

You need to set the property after the view is Loaded

XAML

<Window x:Class="UniformWindow.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local ="clr-namespace:UniformWindow"
        Title="MainWindow" Loaded="Window_Loaded">

   <!- your XAMLSnipped goes here->

</Window>

Codebehind

public partial class MainWindow : Window
{
    ExpanderListViewModel vm = new ExpanderListViewModel();
    public MainWindow()
    {
        InitializeComponent();
        StackPanel1.DataContext = vm;
    }

    private void Window_Loaded(object sender, RoutedEventArgs e)
    {
        vm.SelectedExpander = "2";

    }
}

IValueConverter

public class ExpanderToBooleanConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        // to prevent NullRef
        if (value == null || parameter == null)
            return false;

        var sValue = value.ToString();
        var sparam = parameter.ToString();

        return (sValue == sparam);
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (System.Convert.ToBoolean(value)) return parameter;
        return null;
    }
}

I did it like this

<StackPanel Name="StackPanel1">
    <Expander Header="Expander 1" Expanded="Expander_Expanded">
        <TextBlock>Expander 1</TextBlock>
    </Expander>
    <Expander Header="Expander 2" Expanded="Expander_Expanded">
        <TextBlock>Expander 2</TextBlock>
    </Expander>
    <Expander Header="Expander 3" Expanded="Expander_Expanded" >
        <TextBlock>Expander 3</TextBlock>
    </Expander>
    <Expander Header="Expander 4" Expanded="Expander_Expanded" >
        <TextBlock>Expander 4</TextBlock>
    </Expander>
</StackPanel>


private void Expander_Expanded(object sender, RoutedEventArgs e)
{
    foreach (Expander exp in StackPanel1.Children)
    {
        if (exp != sender)
        {
            exp.IsExpanded = false;
        }
    }
}
مرخصة بموجب: CC-BY-SA مع الإسناد
لا تنتمي إلى StackOverflow
scroll top