Search code examples
c#winformsdata-bindinglistbox

Winforms ListBox with DataSource bound to dotted path of nested object crashes when intermediate object of dotted path is null


  • The ListBox's DataSource is bound to Detail.Tags.
  • When I select the first row, ListBox populates as expected.
  • When I select the second row, the expected (and desired) result is that the ListBox simply displays nothing, because ItemB's Detail property is purposely null for demonstration purposes, so ItemB's Detail.Tags doesn't exist.
  • Actual result is that program crashes to desktop with System.ArgumentException: 'Complex DataBinding accepts as a data source either an IList or an IListSource.'

screen capture

Minimal reproducible example:

    public partial class Form1 : Form
    {
        private readonly IList<Item> _items;
        private BindingSource _bs = new BindingSource(){ DataSource = typeof(Item) };


        public Form1()
        {
            InitializeComponent();
            _items = GenerateSampleItems();
        }


        private void Form1_Load(object sender, EventArgs e)
        {
            dataGridView1.DataSource = _items;
            listBox1.DataBindings.Add(new Binding("DataSource", _bs, "Detail.Tags", false, DataSourceUpdateMode.Never));
        }
        

        private void DataGridView1_SelectionChanged(object sender, EventArgs e)
        {
            if (dataGridView1.SelectedRows.Count == 1)
            {
                _bs.DataSource = dataGridView1.SelectedRows[0].DataBoundItem;
            }
            else
            {
                _bs.DataSource = typeof(Item);
            }
        }


        private IList<Item> GenerateSampleItems()
        {
            return new List<Item>()
            {
                new Item()
                {
                    Name = "ItemA"
                    ,Detail = new Detail()
                    {
                         Expiration = new DateTime(2024,1,1)
                        ,Tags       = new BindingList<Tag>(new List<Tag>()
                                                          {
                                                              new Tag() 
                                                              { 
                                                                   TagName = "FirstT"
                                                                  ,TagValue = "FirstV"
                                                              }
                                                              ,new Tag() 
                                                              { 
                                                                   TagName = "SecondT"
                                                                  ,TagValue = "SecondV"
                                                              }
                                                          })
                    }
                }
                ,new Item()
                {
                    Name = "ItemB"
                    // Detail purposely omitted
                }
                ,new Item()
                {
                    Name = "ItemC"
                    // Detail purposely omitted
                }

            };
        }
    }


    class Item
    {
        public string Name              { get; set; }
        public Detail Detail            { get; set; }
    }

    public class Detail
    {
        public DateTime Expiration      { get; set; }
        public BindingList<Tag> Tags    { get; set; }      
    }

    public class Tag
    {
        public string TagName           { get; set; }
        public string TagValue          { get; set; }
    }

Solution

  • You can solve this problem by Creating a BindingSource for each model:

    1. Main BindingSource where its DataSource property is set to a list of Item. This one is the DataGridView.DataSource.
    2. Second BindingSource to navigate the Detail data members of the main BindingSource.
    3. Third one to navigate and display the Tags data members of the detail's BindingSource. This one is the ListBox.DataSource.
    public partial class Form1 : Form
    {
        private IList<Item> _items;
        private BindingSource _bsItems, _bsDetail, _bsTags;
    
        public Form1()
        {
            InitializeComponent();
        }
    
        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
            _items = GenerateSampleItems();
            _bsItems = new BindingSource(_items, null);
            _bsDetail = new BindingSource(_bsItems, "Detail");
            _bsTags = new BindingSource(_bsDetail, "Tags");
            dataGridView1.DataSource = _bsItems;
            listBox1.DataSource = _bsTags;
        }
    
        protected override void OnFormClosed(FormClosedEventArgs e)
        {
            base.OnFormClosed(e);
            _bsItems.Dispose();
            _bsDetail.Dispose();
            _bsTags.Dispose();
        }
    
        private IList<Item> GenerateSampleItems()
        {
            return new List<Item>()
            {
                new Item()
                {
                    Name = "ItemA",
                    Detail = new Detail
                    {
                        Expiration = new DateTime(2024,1,1),
                        Tags = new BindingList<Tag>(new List<Tag>()
                        {
                            new Tag
                            {
                                TagName = "FirstT",
                                TagValue = "FirstV"
                            },
                            new Tag
                            {
                                TagName = "SecondT",
                                TagValue = "SecondV"
                            }
                        })
                    }
                },
                new Item()
                {
                    Name = "ItemB"
                    // Detail purposely omitted
                },
                new Item()
                {
                    Name = "ItemC"
                    // Detail purposely omitted
                }
            };
        }
    }
    
    // Elsewhere within the project's namespace
    public class Item
    {
        public string Name { get; set; }
        public Detail Detail { get; set; }
    
        // Optional: Change, remove as needed...
        public override string ToString()
        {
            return $"Name: {Name} - Detail: {Detail}";
        }
    }
    
    public class Detail
    {
        public DateTime Expiration { get; set; }
        public BindingList<Tag> Tags { get; set; }
    
        // Optional: Change, remove as needed...
        public override string ToString()
        {
            var tags = $"[{string.Join(", ", Tags)}]";
            return $"Expiration: {Expiration} - Tags: {tags}";
        }
    }
    
    public class Tag
    {
        public string TagName { get; set; }
        public string TagValue { get; set; }
    
        // Optional: Change, remove as needed...
        public override string ToString()
        {
            return $"{TagName}: {TagValue}";
        }
    }
    

    That's all. No need to add DataBindings nor to handle the grid's SelectionChanged event as shown in your code snippet.


    On the other hand, if you need to display the selected Item.Detail.Tags, then you need to flatten them in a list whenever the grid's selection changes and bind the result to the ListBox.

    // +
    using System.Linq;
    
    public partial class Form1 : Form
    {
        private BindingSource _bsItems;
    
        public Form1() => InitializeComponent();
    
        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
            _bsItems = new BindingSource(GenerateSampleItems(), null);
            dataGridView1.DataSource = _bsItems;
        }
    
        protected override void OnFormClosed(FormClosedEventArgs e)
        {
            base.OnFormClosed(e);
            _bsItems.Dispose();
        }
    
        private void dataGridView1_SelectionChanged(object sender, EventArgs e)
        {
            listBox1.DataSource = dataGridView1.SelectedCells
                .Cast<DataGridViewCell>()
                .Select(cell => cell.OwningRow).Distinct()
                .Where(row => (row.DataBoundItem as Item)?.Detail != null)
                .SelectMany(row => (row.DataBoundItem as Item).Detail.Tags)
                .ToList();
        }
    }