Collapse all the expanders and expand one of them by default
Asked Answered
O

6

8

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 ?

Outing answered 23/1, 2014 at 9:6 Comment(2)
Does the initialization work if you change the binding mode to OneWay? If IsExpanded is being explicitly set to false during the expander's own initialization, it would reset your viewmodel property.Patriarchate
When I change the Mode to OneWay, on the app startup the Expander get Expanded but when I click on another one the first when still expanded too :(Outing
P
7

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;
Patriarchate answered 27/1, 2014 at 19:15 Comment(1)
Thanks, It breaks the MVVM but I was obliged to use this solution.Outing
D
0

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.

Duane answered 23/1, 2014 at 9:49 Comment(5)
I did that, and put the value 1 in SelectedExpander, but didn't get the 'Expander 1' to get expander at the app startup :(Outing
where and how you set the value to SelectedExpander?Duane
I already implemented INotifyPropertyChanged in the ViewModel, and added OnPropertyChanged in the SelectedExpander. I tested it by adding a breakpoint and the value gets changed in the ViewModel but the View don't get the changes !Outing
For me it is working as you expected only with the Converter changes. Not even added INotifyPropertyChanged.Duane
It's no working with ot without INotifyPropertyChangedOuting
K
0

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.

Kebab answered 27/1, 2014 at 16:54 Comment(4)
nmclean's commebt helped. I had to change the Mode to "OneWay", it worked but I don't know why !Outing
Edit : It's not working, because now, when I click on another Expander the opened one don't get collapsed :(Outing
@Schneider you need TwoWay so that the Converter is executed accordingly. Do you have any other logic regarding SelectedExpander (for instance handling its OnPropertyChanged event)?Kebab
@Schneider Changing to OneWay was only to narrow down the issue. You're still going to need a TwoWay binding to update the viewmodel when a user clicks the expander.Patriarchate
M
0

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

Mikiso answered 27/1, 2014 at 22:6 Comment(0)
D
0

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;
    }
}
Dichotomy answered 28/1, 2014 at 15:11 Comment(0)
C
0

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;
        }
    }
}
Calvert answered 23/11, 2014 at 19:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.