Search code examples
xamluwpobservablecollectiondependency-propertiesxbind

UWP DependencyProperty with ObservableCollection doesn't update UI when working with base abstract class


I'm facing some weird behavior of ObservableCollection, that is used with DependencyProperty. I've created minimal reproducible scenario here: https://github.com/aosyatnik/UWP_ObservableCollection_Issue.

There are 2 issues, that I see and can not explain.

Here is my MainViewModel:

using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace UWP_ObservableCollection
{
    public class MainViewModel : BaseViewModel
    {
        public IList<ItemViewModel> ItemsAsList { get; private set; }
        public ObservableCollection<ItemViewModel> ItemsAsObservableCollection { get; private set; }
        public IList<ItemViewModel> ItemsRecreatedList { get; private set; }

        public MainViewModel()
        {
            ItemsAsList = new List<ItemViewModel>();
            ItemsAsObservableCollection = new ObservableCollection<ItemViewModel>();
            ItemsRecreatedList = new List<ItemViewModel>();
        }

        public void AddNewItem()
        {
            var newItem = new ItemViewModel();

            // First try: add to list and raise property change - doesn't work.
            ItemsAsList.Add(newItem);
            RaisePropertyChanged(nameof(ItemsAsList));

            // Second try: with ObservableCollection - doesn't work?
            ItemsAsObservableCollection.Add(newItem);

            // Third try: recreate the whole collection - works
            ItemsRecreatedList.Add(newItem);
            ItemsRecreatedList = new List<ItemViewModel>(ItemsRecreatedList);
            RaisePropertyChanged(nameof(ItemsRecreatedList));
        }
    }
}

Also ItemViewModel.cs:

namespace UWP_ObservableCollection
{
    public class ItemViewModel : BaseViewModel
    {
        private static int Counter;
        public string Text { get; private set; }

        public ItemViewModel()
        {
            Counter++;
            Text = $"{Counter}";
        }
    }
}

Here is MainPage.xaml:

<Page
    x:Class="UWP_ObservableCollection.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:UWP_ObservableCollection"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
    Loaded="Page_Loaded">

    <StackPanel>
        <StackPanel Orientation="Vertical">
            <TextBlock>Items as List</TextBlock>
            <local:MyItemsControl ItemsSource="{Binding ItemsAsList}"/>
        </StackPanel>

        <StackPanel Orientation="Vertical">
            <TextBlock>Items as ObservableCollection</TextBlock>
            <local:MyItemsControl ItemsSource="{Binding ItemsAsObservableCollection}"/>
        </StackPanel>

        <StackPanel Orientation="Vertical">
            <TextBlock>Items recreated list</TextBlock>
            <local:MyItemsControl ItemsSource="{Binding ItemsRecreatedList}"/>
        </StackPanel>

        <Button Click="Button_Click">Add new item</Button>
    </StackPanel>
</Page>

MainPage.xaml.cs:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Data;
using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Navigation;

// The Blank Page item template is documented at https://go.microsoft.com/fwlink/?LinkId=402352&clcid=0x409

namespace UWP_ObservableCollection
{
    /// <summary>
    /// An empty page that can be used on its own or navigated to within a Frame.
    /// </summary>
    public sealed partial class MainPage : Page
    {
        public MainViewModel MainViewModel
        {
            get => DataContext as MainViewModel;
        }

        public MainPage()
        {
            this.InitializeComponent();
        }

        private void Page_Loaded(object sender, RoutedEventArgs e)
        {
            DataContext = new MainViewModel();
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            MainViewModel.AddNewItem();
        }
    }
}

MyItemsControl.xaml:

<UserControl
    x:Class="UWP_ObservableCollection.MyItemsControl"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:UWP_ObservableCollection"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    d:DesignHeight="300"
    d:DesignWidth="400">

    <Grid>
        <ItemsControl ItemsSource="{x:Bind ItemsSource, Mode=OneWay}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Text}" />
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
    </Grid>
</UserControl>

MyItemsControl.xaml.cs:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Data;
using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Navigation;

// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236

namespace UWP_ObservableCollection
{
    public sealed partial class MyItemsControl : UserControl
    {
        // This works fine.
        public static readonly DependencyProperty ItemsSourceProperty =
            DependencyProperty.Register(
                "ItemsSource",
                typeof(IList<ItemViewModel>),
                typeof(MyItemsControl),
                new PropertyMetadata(null, ItemsSourcePropertyChanged)
            );

        public IList<ItemViewModel> ItemsSource
        {
            get { return (IList<ItemViewModel>)GetValue(ItemsSourceProperty); }
            set { SetValue(ItemsSourceProperty, value); }
        }

        // Uncomment this code to see the issue.
        /*
        public static readonly DependencyProperty ItemsSourceProperty =
           DependencyProperty.Register(
               "ItemsSource",
               typeof(IList<BaseViewModel>),
               typeof(MyItemsControl),
               new PropertyMetadata(null, ItemsSourcePropertyChanged)
           );

        public IList<BaseViewModel> ItemsSource
        {
            get
            {
                var values = GetValue(ItemsSourceProperty) as IEnumerable<BaseViewModel>;
                if (values is null)
                {
                    return null;
                }
                return values.ToList();
            }
            set { SetValue(ItemsSourceProperty, value); }
        }
        */

        private static void ItemsSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            Debug.WriteLine("Items changed");
        }

        public MyItemsControl()
        {
            this.InitializeComponent();
        }
    }
}

You need to make the next steps:

  1. Build and run app.
  2. See there are 3 MyItemsControl that are using 3 different data sources - ItemsAsList, ItemsAsObservableCollectionand ItemsRecreatedList. Check MainViewModel and find, that there are 3 sources:
    • IList<ItemViewModel> ItemsAsList
    • ObservableCollection<ItemViewModel> ItemsAsObservableCollection
    • IList<ItemViewModel> ItemsRecreatedList
  3. Click on "Add new item". You should see, that 2nd and 3rd collections are updated. Check in MainViewModel method called `AddNewItem. It should add the item to each collection. First question: why the item is added to the first collection, but UI is not updated even if RaisePropertyChanged is called?
  4. Stop app.
  5. Go to MyItemsControl.xaml.cs find commented code, uncomment it and comment previous code. This changes IList<ItemViewModel> to IList<BaseViewModel>.
  6. Now rebuild app and run it once more. Try to click again on "Add new item" and notice, that ObservableCollection is not updated. Second question: why ObservableCollection doesn't trigger getter anymore?

Again you can find all of these in repo!

I appreciate your help, maybe I'm missing something! I'm very interested in the second question and have no idea why it doesn't work. Hope, that you will help me!


Solution

  • Ань, that's quite a work to make your issue easy to reproduce. Good job!

    First thing to keep in mind is that collection binding relies on 2 interfaces: INotifyPropertyChanged and INotifyCollectionChanged and that ObservableCollection<T> implements both of them, while IList<T> implements neither.
    Responsibilities of the INotifyCollectionChanged is to notify event subscribers about added, replaced, moved, or deleted items in a collection that implements it.

    1. Click on "Add new item". You should see, that 2nd and 3rd collections are updated. Check in MainViewModel method called `AddNewItem. It should add the item to each collection. First question: why the item is added to the first collection, but UI is not updated even if RaisePropertyChanged is called?

    You add 1 item to 3 collection-backed data sources. Here what happens:

    • 1st, an IList data source doesn't fire CollectionChanged event: binding is not notified of any changes, no UI updates happen. A call to RaisePropertyChanged(nameof(ItemsAsList)); does nothing, since data source object (ItemsAsList) remains the same, it's only list content that changes. If IList would implement INotifyCollectionChanged (it doesn't) this would work.
    • 2nd, an ObservableCollection data source automatically works as expected: when a new item is added to the collection, binding is notified and an item is added to the UI list.
    • 3rd data source actually re-creates data source collection and you manually notify the binding via RaisePropertyChanged(nameof(ItemsRecreatedList)); that the new data source collection should be used. UI is updated, but in comparison to the 2nd case it's not just 1 item added to the UI list, but an entire list is re-populated in UI tree.
    1. Now rebuild app and run it once more. Try to click again on "Add new item" and notice, that ObservableCollection is not updated. Second question: why ObservableCollection doesn't trigger getter anymore?

    Here you use a customized getter for the dependency property, which at some point calls ToList() method on a collection and returns that. ToList creates a copy of the underlying ObservableCollection content, which is now detached from the data source in the MainViewModel class and is of IList type, so it a) is unaware of subsequent changes in the view-model collection and b) can not notify UI about it.

    public IList<BaseViewModel> ItemsSource
    {
        get
        {
            var values = GetValue(ItemsSourceProperty) as IEnumerable<BaseViewModel>;
            if (values is null)
            {
                return null;
            }
            return values.ToList();
        }
        set { SetValue(ItemsSourceProperty, value); }
    }