Reentrancy exception when adding View to StackLayout.Children from ItemSource and DataTemplate

GaetanFGaetanF USMember ✭✭✭

Hello,

I stumble on a very simple thing here and I can't understand why! I am doing a templated StackLayout and I got a reentrancy exception (InvalidOperationException) when calling Children.Add inside a ItemSource_OnCollectionChanged. I just don't understand why because the two collections are just not the same !
I had a look at this thread and it appears there is the same issue.

I saw an example on the forum that rely on this behaviour. Is it a regression ? Do I do something dumb ??

Here is the code:

Control:

public class TemplateStackLayout : StackLayout
{
    private Stack<View> _viewCache;

    public TemplateStackLayout() : this(4)
    {
    }

    protected TemplateStackLayout(int capacity)
    {
        _viewCache = new Stack<View>(capacity);
    }

    protected virtual View CreateDefault(object item) => new Label { Text = item?.ToString() ?? null};

    protected virtual bool ValidateItemTemplate(DataTemplate dataTemplate) => true;

    protected virtual async void ItemsSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        var dataTemplate = ItemTemplate;
        View content = null;
        object item = null;

        switch (e.Action)
        {
            case NotifyCollectionChangedAction.Add:
                {
                    var offset = e.NewStartingIndex < 0 ? Children.Count : e.NewStartingIndex;

                    for (var i = 0; i < e.NewItems.Count; i++)
                    {
                        item = e.NewItems[i];
                        content = CreateContent(item, dataTemplate);
                        content.Parent = this;
                        content.BindingContext = item;
                        Children.Insert(offset + i, content);   //  Boom
                    }
                }
                break;
            case NotifyCollectionChangedAction.Move:
                {

                }
                break;
            case NotifyCollectionChangedAction.Remove:
                {

                }
                break;
            case NotifyCollectionChangedAction.Replace:
                {

                }
                break;
            case NotifyCollectionChangedAction.Reset:
                SetChildrenContentTo(ItemsSource);
                break;
            default:
                break;
        }
    }

    private void Recycle(View view)
    {
        if (view == null) return;

        view.BindingContext = null;
        view.Parent = null;
        _viewCache.Push(view);
    }

    private void SetChildrenContentTo(IEnumerable source)
    {
        if (source == null) return;

        var dataTemplate = ItemTemplate;
        View content = null;

        Children.Clear();

        foreach (var item in source)
        {
            content = CreateContent(item, dataTemplate);
            content.Parent = this;
            content.BindingContext = item;
            Children.Add(content);
        }
    }

    private View CreateContent(object item, DataTemplate dataTemplate)
    {
        View content = null;

        //  Try pop
        if (_viewCache.Count > 0 && (content = _viewCache.Pop()) != null)
        {
        }
        else if (dataTemplate != null)
        {
            content = (View)dataTemplate.CreateContent();
        }
        else
        {
            content = CreateDefault(item);
        }

        return content;
    }

    #region ItemsSource (Bindable IEnumerable)
    /// <summary>
    /// Manages the binding of the <see cref="ItemsSource"/> property
    /// </summary>
    public static readonly BindableProperty ItemsSourceProperty
        = BindableProperty.Create(propertyName: nameof(ItemsSource),
                                    returnType: typeof(IEnumerable),
                                    declaringType: typeof(TemplateStackLayout),
                                    defaultValue: default(IEnumerable),
                                    defaultBindingMode: BindingMode.OneWay,
                                    propertyChanging: ItemsSourceChanging,
                                    propertyChanged: ItemsSourceChanged);

    public IEnumerable ItemsSource { get => (IEnumerable)GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); }

    private static void ItemsSourceChanging(BindableObject bindable, object oldValue, object newValue)
    {
        var control = (TemplateStackLayout)bindable;

        if (oldValue == null) return;

        if(oldValue is INotifyCollectionChanged ncc)
        {
            ncc.CollectionChanged -= control.ItemsSourceCollectionChanged;
        }

        foreach (var view in control.Children)
        {
            control.Recycle(view);
        }

        control.Children.Clear();
    }

    private static void ItemsSourceChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var control = (TemplateStackLayout)bindable;

        if (newValue == null) return;

        if(newValue is INotifyCollectionChanged ncc)
        {
            ncc.CollectionChanged += control.ItemsSourceCollectionChanged;
        }

        control.SetChildrenContentTo((IEnumerable)newValue);
    }
    #endregion  //  ItemsSource (Bindable IEnumerable)

    #region ItemTemplate (Bindable DataTemplate)
    /// <summary>
    /// Manages the binding of the <see cref="ItemTemplate"/> property
    /// </summary>
    public static readonly BindableProperty ItemTemplateProperty
        = BindableProperty.Create(propertyName: nameof(ItemTemplate),
                                    returnType: typeof(DataTemplate),
                                    declaringType: typeof(TemplateStackLayout),
                                    defaultValue: default(DataTemplate),
                                    defaultBindingMode: BindingMode.OneWay,
                                    propertyChanged: ItemTemplateChanged);

    private static void ItemTemplateChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var control = (TemplateStackLayout)bindable;
        control.Children.Clear();
        control._viewCache.Clear();

        var source = control.ItemsSource;
        if (source != null) control.SetChildrenContentTo(source);
    }

    private static bool ValidateItemTemplate(BindableObject bindable, object value)
    {
        var control = (TemplateStackLayout)bindable;
        return control.ValidateItemTemplate((DataTemplate)value);
    }

    public DataTemplate ItemTemplate { get => (DataTemplate)GetValue(ItemTemplateProperty); set => SetValue(ItemTemplateProperty, value); }
    #endregion  //  ItemTemplate (Bindable DataTemplate)
}

Page/ViewModel/Model

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="AppTest.Views.TemplateStackLayoutTestPage"
             xmlns:Views="clr-namespace:AppTest.Views"
             BindingContext="{StaticResource TemplateStackLayoutTestViewModel}">
    <ContentPage.Resources>
        <ResourceDictionary>
            <Views:TemplateStackLayoutTestViewModel x:Key="TemplateStackLayoutTestViewModel" />
        </ResourceDictionary>
    </ContentPage.Resources>
    <ContentPage.Content>
        <StackLayout>
            <Button Text="Add" Command="{Binding Add}" />
            <Views:TemplateStackLayout ItemsSource="{Binding Source}">
                <Views:TemplateStackLayout.ItemTemplate>
                    <DataTemplate>
                        <Label Text="{Binding Text}" TextColor="Blue" />
                    </DataTemplate>
                </Views:TemplateStackLayout.ItemTemplate>
            </Views:TemplateStackLayout>
                    <Label Text="End" TextColor="Red" />
        </StackLayout>
    </ContentPage.Content>
</ContentPage>

public partial class TemplateStackLayoutTestPage : ContentPage
{
    public TemplateStackLayoutTestPage ()
    {
        InitializeComponent ();
    }
}

class TemplateStackLayoutTestViewModel
{
    public TemplateStackLayoutTestViewModel()
    {
        Add = CommandFactory.Create(() => Source.Add(new TemplateStackLayoutTestModel() { Text = Guid.NewGuid().ToString() }));
    }

    public ObservableCollection<TemplateStackLayoutTestModel> Source { get; } = new ObservableCollection<TemplateStackLayoutTestModel>();

    public ICommand Add { get; }
}

class TemplateStackLayoutTestModel
{
    public string Text { get; set; }
}

Tested on a Galaxy A3 2016 with Android 6.0.1 and on the VS Emulator with Android 4.4 in Debug and shared runtime
Nuget: Xamarin Forms v2.5.0.280555

Trace (from the Galaxy):

    04-18 11:11:57.872 I/MonoDroid(15990): UNHANDLED EXCEPTION:
04-18 11:11:57.922 I/MonoDroid(15990): System.InvalidOperationException: Cannot change ObservableCollection during a CollectionChanged event.
04-18 11:11:57.922 I/MonoDroid(15990):   at System.Collections.ObjectModel.ObservableCollection`1[T].CheckReentrancy () [0x0002f] in <bcdc1df2b3724ab69797f3819a126346>:0 
04-18 11:11:57.922 I/MonoDroid(15990):   at System.Collections.ObjectModel.ObservableCollection`1[T].RemoveItem (System.Int32 index) [0x00000] in <bcdc1df2b3724ab69797f3819a126346>:0 
04-18 11:11:57.922 I/MonoDroid(15990):   at System.Collections.ObjectModel.Collection`1[T].Remove (T item) [0x00027] in <fcbf47a04b2e4d90beafbae627e1fca4>:0 
04-18 11:11:57.922 I/MonoDroid(15990):   at Xamarin.Forms.Layout.OnInternalAdded (Xamarin.Forms.View view) [0x0000b] in D:\agent_work\1\s\Xamarin.Forms.Core\Layout.cs:414 
04-18 11:11:57.922 I/MonoDroid(15990):   at Xamarin.Forms.Layout.InternalChildrenOnCollectionChanged (System.Object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) [0x00089] in D:\agent_work\1\s\Xamarin.Forms.Core\Layout.cs:406 
04-18 11:11:57.922 I/MonoDroid(15990):   at (wrapper delegate-invoke) <Module>.invoke_void_object_NotifyCollectionChangedEventArgs(object,System.Collections.Specialized.NotifyCollectionChangedEventArgs)
04-18 11:11:57.922 I/MonoDroid(15990):   at System.Collections.ObjectModel.ObservableCollection`1[T].OnCollectionChanged (System.Collections.Specialized.NotifyCollectionChangedEventArgs e) [0x0000f] in <bcdc1df2b3724ab69797f3819a126346>:0 
04-18 11:11:57.922 I/MonoDroid(15990):   at System.Collections.ObjectModel.ObservableCollection`1[T].OnCollectionChanged (System.Collections.Specialized.NotifyCollectionChangedAction action, System.Object item, System.Int32 index) [0x00009] in <bcdc1df2b3724ab69797f3819a126346>:0 
04-18 11:11:57.922 I/MonoDroid(15990):   at System.Collections.ObjectModel.ObservableCollection`1[T].InsertItem (System.Int32 index, T item) [0x00024] in <bcdc1df2b3724ab69797f3819a126346>:0 
04-18 11:11:57.922 I/MonoDroid(15990):   at System.Collections.ObjectModel.Collection`1[T].Add (T item) [0x00020] in <fcbf47a04b2e4d90beafbae627e1fca4>:0 
04-18 11:11:57.922 I/MonoDroid(15990):   at Xamarin.Forms.ObservableWrapper`2[TTrack,TRestrict].Add (TRestrict item) [0x0004b] in D:\agent_work\1\s\Xamarin.Forms.Core\ObservableWrapper.cs:35 
04-18 11:11:57.922 I/MonoDroid(15990):   at AppTest.Views.TemplateStackLayout+<ItemsSourceCollectionChanged>d__5.MoveNext () [0x000ee] in C:\Projects\Mobile\AppTest\AppTest\Views\TemplateStackLayout.cs:47

Answers

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭

    System.InvalidOperationException: Cannot change ObservableCollection during a CollectionChanged event.

    Sounds like a pretty self-explanatory error. What's the confusion? You can't change a collection in the handler for CollectionChanged - that would result in a circular reference/infinite loop.

  • GaetanFGaetanF USMember ✭✭✭

    You can't change a collection in the handler for CollectionChanged - that would result in a circular reference/infinite loop

    Totally agreed.

    The thing here is that I am modifying Children of StackLayout in response to ItemSource.CollectionChanged event, so this statement does not apply here and that is what I don't understand...

  • ClintStLaurentClintStLaurent USUniversity ✭✭✭✭✭

    Children is a collection. Its the item source. So if you're changing the children in response to the source collection changing... I'm pretty sure they are one and the same. I sounds like its all one big loop. Then again I might be confusing myself as well as it isn't my code and I can't step through it.

  • GaetanFGaetanF USMember ✭✭✭

    Children is the item source of type IList<View> of StackLayout but ItemSource is the bindable property of type IEnumerable of my custom control.

    Both are not directly related, e.g. in pseudocode:

    View = DataTemplate.CreateView();
    View.BindingContext = [ItemSource];
    [Children] = View;
    

    The code throws here Children.Insert(offset + i, content); // Boom, where content is the View above in the pseudocode.

Sign In or Register to comment.