xamarinxamarin.formsbindableproperty

BindableProperty of type IList<View> with contents set in XAML is never updated


I am making a custom ContentView for a common container type I use throughout my app. The custom view has two bindable properties: the header (string) to be displayed in the upper left corner, and a list of buttons (List) to be displayed in the upper right corner.

The header property (and the Contents property) works as expected, but the buttons property does not work at all. The ButtonsPropertyChanged does never fire, and there is no binding error message in the output console. What am I doing wrong?

HomePage.xaml:

<controls:SectionContainer Header="Text blah"> <!-- Header works -->

    <controls:SectionContainer.Buttons>
        <!-- This does NOT work - buttons are not shown -->
        <ImageButton Source="{Binding SomeIcon}"></ImageButton>
        <ImageButton Source="{Binding AnotherIcon}"></ImageButton>
    </controls:SectionContainer.Buttons>

    <!-- Actual contents here -- this works, the contents are shown -->
</controls:SectionContainer>

SectionContainer.xaml:

<ContentView.Content>
    <Frame Style="{StaticResource SectionFrame}">

        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"></ColumnDefinition>
                <ColumnDefinition Width="Auto"></ColumnDefinition>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="30"></RowDefinition>
                <RowDefinition Height="*"></RowDefinition>
            </Grid.RowDefinitions>

            <Label x:Name="HeaderLabel" Grid.Column="0" Grid.Row="0" VerticalTextAlignment="Start" VerticalOptions="Start" Style="{StaticResource Header}"></Label>

            <StackLayout x:Name="ButtonsContainer" Grid.Row="0" Grid.Column="1" Orientation="Horizontal" HorizontalOptions="End"></StackLayout>

            <StackLayout Grid.Column="0" Grid.Row="1" x:Name="Container" Style="{StaticResource Section}"></StackLayout>

    </Grid>

    </Frame>
</ContentView.Content>

SectionContainer.xaml.cs:

    [XamlCompilation(XamlCompilationOptions.Compile)]
    [ContentProperty("Contents")]
    public partial class SectionContainer : ContentView
    {
        public IList<View> Contents => Container.Children;

        public string Header
        {
            get => (string)GetValue(HeaderProperty);
            set => SetValue(HeaderProperty, value);
        }
        public static readonly BindableProperty HeaderProperty = BindableProperty.Create(
            nameof(Header),
            typeof(string),
            typeof(SectionContainer),
            "",
            propertyChanged: HeaderPropertyChanged);

        private static void HeaderPropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            ((SectionContainer) bindable).HeaderLabel.Text = (string) newvalue;
        }

        public IList<View> Buttons
        {
            get => (IList<View>)GetValue(ButtonsProperty);
            set => SetValue(ButtonsProperty, value);
        }

        public static readonly BindableProperty ButtonsProperty = BindableProperty.Create(
            nameof(Buttons),
            typeof(IList<View>),
            typeof(SectionContainer),
            new List<View>(),
            BindingMode.OneWay,
            propertyChanged: ButtonsPropertyChanged);

        private static void ButtonsPropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            Debug.WriteLine(newvalue); // This is never printed, and there are no error messages

            var children= ((SectionContainer)bindable).ButtonsContainer.Children;
            children.Clear();
            foreach (var child in (IList<View>) newvalue)
                children.Add(child);
        }

        public SectionContainer()
        {
            InitializeComponent();
        }
    }

Solution

  • Xamarin.Forms doesn't set the property, it adds elements to the object created as default value.

    Therefore, an ObservableCollection created using defaultValueCreator (instead of defaultValue), where we listen for changes and populate the user control seems to work. Not sure if we need the propertyChanged event at all, or if we will ever need to handle any other ObservableCollection events than single element adds, but I kept it in just in case.

        public static readonly BindableProperty ButtonsProperty = BindableProperty.Create(
            nameof(Buttons),
            typeof(IList<View>),
            typeof(SectionContainer),
            propertyChanged: ButtonsPropertyChanged,
            defaultValueCreator: CreateObservableList);
    
        private static object CreateObservableList(BindableObject bindable)
        {
            var oc = new ObservableCollection<View>();
    
            // It appears Xamarin.Forms doesn't create a list containing the Views from the XAML, it uses the default value (list),
            // and adds the Views one by one. Therefore, we create the default value as an ObservableCollection, then we listen
            // for changes to this collection.
            oc.CollectionChanged += (o, e) =>
            {
                if (e.Action == NotifyCollectionChangedAction.Add)
                {
                    // If this is an Add operation, simply add the view to the StackLayout.
                    var children = ((SectionContainer) bindable).ButtonsContainer.Children;
                    foreach (var child in e.NewItems.Cast<View>())
                        children.Add(child);
                }
                else
                {
                    // Otherwise, recreate the StackLayout by clearing it, and adding all children all over again.
                    // (Won't bother handling all cases, since the normal one seems to be Add anyway.)
                    ButtonsPropertyChanged(bindable, null, o);
                }
            };
            return oc;
        }