1

I am trying to make a WPF listbox replicate the behaviour of an old Winforms CheckedListBox, or the checked list box used in e.g. AnkhSVN. I have seen examples that show how to use a DataTemplate to create a check box for every time (e.g. Wpf CheckedListbox - how to get selected item), but this feels very clunky compared to the winforms control:

  • The logic of "If the user changes a check state, ensure that check state changes for all selected items" is not present by default.
  • The hit area to change an item from checked to unchecked is the box /and/ the title, rather than just the box as in Winforms

I can handle the first issue by adding a listener to the PropertyChanged event on each item in the bound collection, and if IsChecked changes, then set IsChecked to the same value for all currently selected items.

However, I cannot find a good solution to the second issue. By splitting the DataTemplate into a Checkbox with no title, and a TextBlock with the title, I can reduce the hit area to change the check state to only the desired square. However, all mouse interaction which hits the TextBlock does nothing - I would like it to behave the same as in a normal listbox, or in the dead space outside of the Textblock: If the user is holding shift, then select everything up to and including this item, if not, then clear the selection and select only this item. I could try to implement something where I handled Mouse* events on the TextBlock, but that seems brittle and inelegant - I'd be trying to recreate the exact behaviour of the ListBox, rather than passing events to the listbox.

Here's what I've got currently:

XAML:

<ListBox x:Name="_lstReceivers" SelectionMode="Extended" Margin="10,41,6,15"
                 ItemsSource="{Binding Receivers}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <ListBoxItem>
                        <StackPanel Orientation="Horizontal">
                            <CheckBox IsChecked="{Binding IsChecked}" IsHitTestVisible="True"/>
                            <TextBlock Text="{Binding Item}" Background="{x:Null}" IsHitTestVisible="False"/><!--Attempt to make it pass mouse events through. Doesn't work. Yuk.-->
                        </StackPanel>
                    </ListBoxItem>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

Code behind to get the "Change all checks at the same time" logic (removed some error handling for clarity):

private void ListBoxItem_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    var item = sender as CheckableItem<Receiver>;
    if (item == null)
        return;

    if (e.PropertyName == nameof(CheckableItem<Receiver>.IsChecked))
    {
        bool newVal = item.IsChecked;
        foreach (CheckableItem<Receiver> changeItem in _lstReceivers.SelectedItems)
        {
            changeItem.IsChecked = newVal;
        }
    }
}

By trying various combinations of Background = "{x:Null}" and IsHitTestVisible="False", I did manage to get the entire item to not respond to mouse click events - but I could not make it have only the Checkbox respond to mouse events, while everything else is passed to the ListBox for proper selection processing.

Any help would be greatly appreciated.

2
  • 1
    Without a good minimal reproducible example, it's not practical to try to get your code to work. But, I would think it should work fine if you just don't mess with the IsHitTestVisible properties at all. BTW, the RadioButton works fine out of the box. you don't need the code-behind you wrote. Just make sure all the RadioButton elements you want grouped together have the same GroupName value. Commented Nov 6, 2017 at 5:32
  • Do you have an example of how to make a RadioButton work? Because if I replace my StackPanel with a Radiobutton, I get a list full of radiobuttons which don't respond to anything. But the main issue is Radiobuttons are still not transparent to mouse events on their text - they intercept clicks, rather than having the ListBox deal with them. That is the main problem that I do not know how to solve, as indicated by the title and the text of my post. Commented Nov 6, 2017 at 15:55

1 Answer 1

3

Answering my own question again.

Well, I couldn't find a clean way to do it, so I ended up setting the ListBoxItem to have IsHitTestVisible="False", and manually tracing mouse events using PreviewMouseDown.

Final code:

XAML:

<ListBox x:Name="_lstReceivers" SelectionMode="Extended" Margin="10,41,6,15"
            ItemsSource="{Binding Receivers}" PreviewMouseDown="_lstReceivers_MouseDown">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <ListBoxItem IsSelected="{Binding IsSelected}" IsHitTestVisible="False">
                <StackPanel Orientation="Horizontal" Background="{x:Null}">
                    <CheckBox IsChecked="{Binding IsChecked}" IsHitTestVisible="True" Checked="CheckBox_Checked" Unchecked="CheckBox_Checked"/>
                    <TextBlock Text="{Binding Item}" Background="{x:Null}" IsHitTestVisible="False"/>
                </StackPanel>
            </ListBoxItem>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

Code behind:

//Logic to handle allowing the user to click the checkbox, but have everywhere else respond to normal listbox logic.
private void _lstReceivers_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
    Visual curControl = _lstReceivers as Visual;

    ListBoxItem testItem = null;

    //Allow normal selection logic to take place if the user is holding shift or ctrl
    if (Keyboard.IsKeyDown(Key.LeftCtrl) || Keyboard.IsKeyDown(Key.RightCtrl) || Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
        return;

    //Find the control which the user clicked on. We require the relevant ListBoxItem too, so we can't use VisualTreeHelper.HitTest (Or it wouldn't be much use)
    for (int i = 0; i < VisualTreeHelper.GetChildrenCount(curControl); i++)
    {
        var testControl = (Visual)VisualTreeHelper.GetChild(curControl, i);
        var rect = VisualTreeHelper.GetDescendantBounds(testControl);
        var pos = e.GetPosition((IInputElement)curControl) - VisualTreeHelper.GetOffset(testControl);
        if (!rect.Contains(pos))
            continue;
        else
        {
            //There are multiple ListBoxItems in the tree we walk. Only take the first - and use it to remember the IsSelected property.
            if (testItem == null && testControl is ListBoxItem)
                testItem = testControl as ListBoxItem;

            //If we hit a checkbox, handle it here
            if (testControl is CheckBox)
            {
                //If the user has hit the checkbox of an unselected item, then only change the item they have hit.
                if (!testItem.IsSelected)
                    dontChangeChecks++;

                ((CheckBox)testControl).IsChecked = !((CheckBox)testControl).IsChecked;

                //If the user has hit the checkbox of a selected item, ensure that the entire selection is maintained (prevent normal selection logic).
                if (testItem.IsSelected)
                    e.Handled = true;
                else
                    dontChangeChecks--;

                return;
            }
            //Like recursion, but cheaper:
            curControl = testControl;
            i = -1;
        }
    }
}

//Guard variable
int dontChangeChecks = 0;
//Logic to have all selected listbox items change at the same time
private void CheckBox_Checked(object sender, RoutedEventArgs e)
{
    if (dontChangeChecks > 0)
        return;
    var newVal = ((CheckBox)sender).IsChecked;
    dontChangeChecks++;
    try
    {
        //This could be improved by making it more generic.
        foreach (CheckableItem<Receiver> item in _lstReceivers.SelectedItems)
        {
            item.IsChecked = newVal.Value;
        }
    }
    finally
    {
        dontChangeChecks--;
    }
}

This solution works, but I don't like the coupling it introduces between my code and the exact behaviour of the ListBox implementation:

  • Checking the Keyboard state
  • It won't handle dragging if the user starts dragging inside a checkbox
  • It should happen on mouseup, not mousedown. But it's close enough for my needs.

PS: The bound class, even though it's irrelevant and obvious what it would have:

public class CheckableItem<T> : INotifyPropertyChanged
{
    public T Item { get; set; }

    private bool _isSelected;
    public bool IsSelected
    {
        get => _isSelected;
        set
        {
            if (_isSelected == value)
                return;
            _isSelected = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected)));
        }
    }

    private bool _checked;
    public bool IsChecked
    {
        get => _checked;
        set
        {
            if (_checked == value)
                return;
            _checked = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsChecked)));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.