3
votes

I should like to manipulate an ObservableCollection of strings by binding it to the ItemsSource property of a ListBox and setting the item template to a TextBox.

My problem is that the items in the ObservableCollection do not get updated when I edit them in the TextBox items that the ListBox contains. What am I doing wrong?

The XAML of the minimum working example is

    <Window x:Class="ListBoxUpdate.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="ListBoxUpdate" Height="300" Width="300"
>
<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="*" />
    </Grid.ColumnDefinitions>
    <Grid Grid.Column="0">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <Button Grid.Row="0" Content="Show items" Click="HandleButtonClick"/>
        <TextBlock Grid.Row="1" x:Name="textBlock" />
    </Grid>
    <ListBox
        Grid.Column="1"
        ItemsSource="{Binding Strings, Mode=TwoWay}">
        <ListBox.ItemTemplate>
            <DataTemplate>
                <TextBox Text="{Binding ., Mode=TwoWay}" />
            </DataTemplate>
        </ListBox.ItemTemplate>
    </ListBox>
</Grid>

whilst the corresponding code-behind is

    using System;
    using System.Collections.ObjectModel;
    using System.Windows;

    namespace ListBoxUpdate
    {
        public partial class Window1 : Window
        {
            public ObservableCollection<string> Strings { get; set; }

            public Window1()
            {
                InitializeComponent();

                Strings = new ObservableCollection<string>(new string[] { "one", "two", "three" });
                this.DataContext = this;
            }

            void HandleButtonClick(object sender, RoutedEventArgs e)
            {
                string text = "";

                for (int i = 0; i < Strings.Count; i++) {
                    text += Strings[i] + Environment.NewLine;
                }

                textBlock.Text = text;
            }
        }
    }

Your advice is much appreciated.

1
Strings are immutable, they can't be changed. If you want to be able to change the text, you will need a wrapper class with a string property. Then you can change the property on the class. - Brandon Kramer
@BrandonKramer: Thank you very much, you are right. I replaced the strings with a StringWrapper internal class and with that, it works. - tethered.sun
Glad I could help. - Brandon Kramer
@BrandonKramer: If you post your comment as an answer, I should be glad to accept it. I am rather inexperienced in forums like this, but I think it would be impolite to answer my own question on the basis of the comment of someone else. - tethered.sun
@tethered: actually, this doesn't have anything to do with the string type being immutable. It's simply because WPF binding can't change the source object being bound to; it can only change properties on that object. But yes, the answer is still the same: put the value you want to change in a property of the source object, instead of making it the source object. You should feel free to self-answer...Brandon may not want to take the time to write a full-blown answer, but you can easily post the code you had to write to fix the problem, since you already have it. - Peter Duniho

1 Answers

2
votes

As comments by @BrandonKramer and @Peter Duniho show, the solution is that data binding cannot change the object itself to which it is bound, only the properties of that object.

Consequently, I have to create a wrapper class whose property will then be the string I want to manipulate. The code-behind is now

using System;
using System.Collections.ObjectModel;
using System.Windows;

namespace ListBoxUpdate
{
    public partial class Window1 : Window
    {
        public ObservableCollection<StringWrapper> Strings { get; set; }

        public class StringWrapper 
        {
            public string Content { get; set; }

            public StringWrapper(string content)
            {
                this.Content = content;
            }

            public static implicit operator StringWrapper(string content)
            {
                return new Window1.StringWrapper(content);
            }
        }

        public Window1()
        {
            InitializeComponent();

            this.Strings = new ObservableCollection<StringWrapper>(new StringWrapper[] { "one", "two", "three" });
            this.DataContext = this;
        }

        void HandleButtonClick(object sender, RoutedEventArgs e)
        {
            string text = "";

            for (int i = 0; i < Strings.Count; i++) {
                text += Strings[i].Content + Environment.NewLine;
            }

            textBlock.Text = text;
        }
    }
}

and the XAML has to be modified only at one point

<ListBox
    Grid.Column="1"
    ItemsSource="{Binding Strings, Mode=TwoWay}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <TextBox Text="{Binding Content, Mode=TwoWay}" />
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

Thank you, @BrandonKramer and @Peter Duniho.

UPDATE: I felt uncomfortable with defining a wrapper just to manipulate objects, and the problem of editing an object itself in a list and not one of its properties seemed to me universal, so I tried to find another solution. I rather decided to hook to the LostFocus event of the TextBox in the ItemTemplate.

In this case, the problem is finding the index in the ListBox from the templated TextBox that is just losing focus. I cannot use the SelectedIndex property of the ListBox as at the time when a LostFocus fires, it is already set to a different ListBoxItem.

I searched quite a bit and have found Dennis Troller’s answer here: WPF ListBoxItems with DataTemplates - How do I reference the CLR Object bound to ListBoxItem from within the DataTemplate? The trick is to get the DataContext of the TextBox losing focus and use the ItemContainerGenerator of the ListBox to first identify the container (with ItemContainerGenerator.ContainerFromItem) then obtain the index in the list (with ItemContainerGenerator.IndexFromContainer).

The code-behind is now

using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;

namespace ListBoxUpdate
{

    public partial class Window1 : Window
    {
        public ObservableCollection<string> Strings { get; set; }

        public Window1()
        {

            InitializeComponent();

            this.Strings = new ObservableCollection<string>(new string[] { "one", "two", "three" });
            this.DataContext = this;
        }

        void HandleButtonClick(object sender, RoutedEventArgs e)
        {
            string text = "";

            for (int i = 0; i < Strings.Count; i++) {
                text += Strings[i] + Environment.NewLine;
            }

            textBlock.Text = text;
        }

        void HandleTextBoxLostFocus(object sender, RoutedEventArgs e)
        {
            // https://stackguides.com/questions/765984/wpf-listboxitems-with-datatemplates-how-do-i-reference-the-clr-object-bound-to?rq=1, @Dennis Troller's answer.
            int index;
            object item;
            DependencyObject container;
            TextBox textBox = sender as TextBox;

            if (textBox == null) return;

            item = textBox.DataContext;
            container = listBox.ItemContainerGenerator.ContainerFromItem(item);

            if (container != null) {
                index = listBox.ItemContainerGenerator.IndexFromContainer(container);
                if (textBox.Text != Strings[index]) {
                    Strings[index] = textBox.Text;
                }
            }
        }
    }
}

with the full XAML as follows (I made the binding to the text one-way):

<Window x:Class="ListBoxUpdate.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="ListBoxUpdate" Height="300" Width="300"
    >
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <Grid Grid.Column="0">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <Button Grid.Row="0" Content="Show items" Click="HandleButtonClick"/>
            <TextBlock Grid.Row="1" x:Name="textBlock" />
        </Grid>
        <ListBox
            x:Name="listBox"
            Grid.Column="1"
            ItemsSource="{Binding Strings}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <TextBox 
                        Text="{Binding ., Mode=OneWay}"
                    LostFocus="HandleTextBoxLostFocus"/>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </Grid>
</Window>