Skip to content

Commit 1d01752

Browse files
author
Alberto Aldegheri
committed
Fixes leaks and EmptyViewTemplate issues
- EmptyViewTemplate should inherit BindingContext even when it changes - BindingContext must be cleared on removed children to: - Behave like standard BindingContext inheritance - Avoid unwanted side-effects - Avoid leaking views in memory
1 parent f04c5af commit 1d01752

2 files changed

Lines changed: 165 additions & 3 deletions

File tree

src/Controls/src/Core/BindableLayout/BindableLayout.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ void CreateChildren()
313313
return;
314314
}
315315

316-
layout.Clear();
316+
ClearChildren(layout);
317317

318318
UpdateEmptyView(layout);
319319

@@ -326,6 +326,23 @@ void CreateChildren()
326326
}
327327
}
328328

329+
void ClearChildren(IBindableLayout layout)
330+
{
331+
var index = layout.Children.Count;
332+
while (--index >= 0)
333+
{
334+
var child = (View)layout.Children[index]!;
335+
layout.RemoveAt(index);
336+
337+
// Empty view inherits the BindingContext automatically,
338+
// we don't want to mess up with automatic inheritance.
339+
if (child == _currentEmptyView) continue;
340+
341+
// Given that we've set BindingContext manually on children we have to clear it on removal.
342+
child.BindingContext = null;
343+
}
344+
}
345+
329346
void UpdateEmptyView(IBindableLayout layout)
330347
{
331348
if (_currentEmptyView == null)
@@ -369,7 +386,6 @@ View CreateEmptyView(object emptyView, DataTemplate dataTemplate)
369386
if (dataTemplate != null)
370387
{
371388
var view = (View)dataTemplate.CreateContent();
372-
view.BindingContext = (layout as BindableObject).BindingContext;
373389
return view;
374390
}
375391

@@ -390,7 +406,15 @@ void ItemsSourceCollectionChanged(object sender, NotifyCollectionChangedEventArg
390406

391407
e.Apply(
392408
insert: (item, index, _) => layout.Insert(CreateItemView(item, layout), index),
393-
removeAt: (item, index) => layout.RemoveAt(index),
409+
removeAt: (item, index) =>
410+
{
411+
var child = (View)layout.Children[index]!;
412+
layout.RemoveAt(index);
413+
414+
// It's our responsibility to clear the BindingContext for the children
415+
// Given that we've set them manually in CreateItemView
416+
child.BindingContext = null;
417+
},
394418
reset: CreateChildren);
395419

396420
// UpdateEmptyView is called from within CreateChildren, therefor skip it for Reset

src/Controls/tests/Core.UnitTests/BindableLayoutTests.cs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,77 @@ public void ValidateBindableProperties()
389389
Assert.Equal(itemTemplateSelector, layout.GetValue(BindableLayout.ItemTemplateSelectorProperty));
390390
}
391391

392+
[Fact]
393+
public async Task DoesNotLeakChildListeningToBindingContextOnRemove()
394+
{
395+
var myViewModel = new MyViewModel();
396+
var list = new ObservableCollection<MyViewModel> { myViewModel };
397+
398+
var triggeredCount = 0;
399+
var itemTemplate = new DataTemplate(() => new MyViewModelBoundComponent(onTextChangedCallback: () => triggeredCount++));
400+
401+
var layout = new StackLayout { IsPlatformEnabled = true };
402+
BindableLayout.SetItemTemplate(layout, itemTemplate);
403+
BindableLayout.SetItemsSource(layout, list);
404+
405+
list.RemoveAt(0);
406+
407+
// Run GC
408+
await Task.Yield();
409+
GC.Collect();
410+
GC.WaitForPendingFinalizers();
411+
412+
// The component should be gone
413+
Assert.Equal(0, MyViewModelBoundComponent.InstanceCount);
414+
}
415+
416+
[Fact]
417+
public async Task DoesNotLeakChildListeningToBindingContextOnClear()
418+
{
419+
var myViewModel = new MyViewModel();
420+
var list = new ObservableCollection<MyViewModel> { myViewModel };
421+
422+
var triggeredCount = 0;
423+
var itemTemplate = new DataTemplate(() => new MyViewModelBoundComponent(onTextChangedCallback: () => triggeredCount++));
424+
425+
var layout = new StackLayout { IsPlatformEnabled = true };
426+
BindableLayout.SetItemTemplate(layout, itemTemplate);
427+
BindableLayout.SetItemsSource(layout, list);
428+
429+
list.Clear();
430+
431+
// Run GC
432+
await Task.Yield();
433+
GC.Collect();
434+
GC.WaitForPendingFinalizers();
435+
436+
// The component should be gone
437+
Assert.Equal(0, MyViewModelBoundComponent.InstanceCount);
438+
}
439+
440+
[Fact]
441+
public async Task EmptyViewTemplateContentInheritsLayoutBindingContext()
442+
{
443+
var list = new ObservableCollection<string>();
444+
445+
var bindingContext = "Foo";
446+
447+
var layout = new StackLayout { IsPlatformEnabled = true, BindingContext = bindingContext };
448+
BindableLayout.SetEmptyViewTemplate(layout, new DataTemplate(() => new Label()));
449+
BindableLayout.SetItemsSource(layout, list);
450+
451+
// Verify that the empty view is bound to layout's binding context
452+
var emptyView = layout.Children.FirstOrDefault() as Label;
453+
Assert.NotNull(emptyView);
454+
Assert.Equal(bindingContext, emptyView.BindingContext);
455+
456+
// Change binding context on layout
457+
layout.BindingContext = bindingContext = "Bar";
458+
459+
// Verify empty view inherited the binding context
460+
Assert.Equal(bindingContext, emptyView.BindingContext);
461+
}
462+
392463
[Fact]
393464
public async Task DoesNotLeak()
394465
{
@@ -530,5 +601,72 @@ public MyDataTemplateSelectorTest(Func<object, BindableObject, DataTemplate> fun
530601
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
531602
=> _func(item, container);
532603
}
604+
605+
class MyViewModel : INotifyPropertyChanged
606+
{
607+
string _text;
608+
609+
public event PropertyChangedEventHandler PropertyChanged;
610+
611+
public string Text
612+
{
613+
get => _text;
614+
set
615+
{
616+
_text = value;
617+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Text)));
618+
}
619+
}
620+
}
621+
622+
/// <summary>
623+
/// When binding context is changed, the component watches for changes in the view model `Text` property.
624+
/// The event handler is removed correctly when the binding context is changed or cleared.
625+
/// </summary>
626+
class MyViewModelBoundComponent : ContentView
627+
{
628+
public static int InstanceCount = 0;
629+
630+
readonly Action _onTextChangedCallback;
631+
MyViewModel _myViewModel;
632+
633+
public string Text => _myViewModel?.Text;
634+
635+
public MyViewModelBoundComponent(Action onTextChangedCallback)
636+
{
637+
++InstanceCount;
638+
_onTextChangedCallback = onTextChangedCallback;
639+
}
640+
641+
~MyViewModelBoundComponent()
642+
{
643+
--InstanceCount;
644+
}
645+
646+
protected override void OnBindingContextChanged()
647+
{
648+
base.OnBindingContextChanged();
649+
650+
if (_myViewModel != null)
651+
{
652+
_myViewModel.PropertyChanged -= OnBindingContextFixturePropertyChanged;
653+
}
654+
655+
_myViewModel = BindingContext as MyViewModel;
656+
657+
if (_myViewModel != null)
658+
{
659+
_myViewModel.PropertyChanged += OnBindingContextFixturePropertyChanged;
660+
}
661+
}
662+
663+
void OnBindingContextFixturePropertyChanged(object sender, PropertyChangedEventArgs e)
664+
{
665+
if (e.PropertyName == nameof(MyViewModel.Text))
666+
{
667+
_onTextChangedCallback?.Invoke();
668+
}
669+
}
670+
}
533671
}
534672
}

0 commit comments

Comments
 (0)