Ciaran Gallagher
Ciaran Gallagher

Reputation: 4020

ContentView binding doesn't work properly

I have a resuable control like this to display a loading spinner:

<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             mc:Ignorable="d"
             x:Class="Framework.Controls.Loading" x:Name="LoadingControl" IsVisible="{Binding LoadingIndicator}"
             HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
  <ContentView.Content>
      <ActivityIndicator HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand" Color="DarkBlue" 
                         IsVisible="{Binding LoadingIndicator}"
                         IsRunning="{Binding LoadingIndicator}">
      </ActivityIndicator>
    </ContentView.Content>
</ContentView>

I am trying to consume it on a page like this:

<controls:Loading LoadingIndicator="{Binding IsLoading}"></controls:Loading>

However, the loading spinner fails to appear on-screen.

When I set the LoadingIndicator property to true, it appears just fine:

<controls:Loading LoadingIndicator="true"></controls:Loading>

My 'IsLoading' binding is definitely working properly, because if I place the following code directly in my XAML page it also works fine:

<ActivityIndicator HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand"
                    Color="DarkBlue" IsVisible="{Binding IsLoading}" IsRunning="{Binding IsLoading}">
</ActivityIndicator>

Therefore, what is it about this that's wrong?

<controls:Loading LoadingIndicator="{Binding IsLoading}"></controls:Loading>

The 'IsLoading' property gets set on each of my pages from my view model. Here is a snippet from the view model:

public ICommand OnSave => new Command(async () =>
{
    IsLoading = true;
    await CreateItem();
    IsLoading = false;
});

The code-behind for my control looks like this:

[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class Loading : ContentView
{
    public static readonly BindableProperty LoadingIndicatorProperty =
        BindableProperty.Create(
            propertyName: nameof(LoadingIndicator), typeof(bool),
            typeof(Loading), default(string), BindingMode.OneWayToSource);

    public bool LoadingIndicator
    {
        get => (bool)GetValue(LoadingIndicatorProperty);
        set => SetValue(LoadingIndicatorProperty, value);
    }

    public Loading()
    {
        InitializeComponent();
        BindingContext = this;
    }
}

Do I need to write code to handle the change if the IsLoading binding gets updated?

This is the full code for the page where I am using the control:

ItemCreatePage.xaml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage Title="{Binding PageTitle}"
             xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:userControls="clr-namespace:Framework.UserControls"
             xmlns:converters="clr-namespace:Framework.ValueConverters"
             xmlns:controls="clr-namespace:Framework.Controls;assembly=Framework.Android"
             x:Class="Framework.Views.Item.ItemCreatePage">
    <ContentPage.Resources>
        <ResourceDictionary>
            <converters:DoubleConverter x:Key="DoubleConverter"></converters:DoubleConverter>
        </ResourceDictionary>
    </ContentPage.Resources>

    <ContentPage.Content>
        <Grid>
            <ScrollView>
                <Grid RowSpacing="0" VerticalOptions="Start">
                  <Grid.RowDefinitions>
                    <RowDefinition Height="*" />
                    <RowDefinition Height="Auto" />
                  </Grid.RowDefinitions>

                  <StackLayout Grid.Row="1" Padding="20,20,20,0" VerticalOptions="Start">

                    <Label Text="Category" />
                    <userControls:BindablePicker
                        ItemsSource="{Binding Categories}"
                        SelectedItem="{Binding Path=Item.CategoryName, Mode=OneWay}"
                        DisplayMemberPath="Name"
                        SelectedValuePath="Id"
                        SelectedValue="{Binding Path=Item.CategoryId, Mode=TwoWay}"/>

                    <Label Text="Description" />
                    <Editor Text="{Binding Item.Description}" HeightRequest="100"/>

                    <Label Text="Area"/>
                    <Entry Text="{Binding Item.LineNumber}"/>

                    <Label Text="Identifier"/>
                    <Entry Text="{Binding Item.Identifier}"/>

                    <Label Text="Code"/>
                    <Entry Text="{Binding Item.Code}"/>

                    <Label Text="Priority" />
                    <userControls:BindablePicker
                        ItemsSource="{Binding Priorities}"
                        SelectedItem="{Binding Path=Item.ItemPriority, Mode=OneWay}"
                        DisplayMemberPath="Name"
                        SelectedValuePath="Id"
                        SelectedValue="{Binding Path=Item.ItemPriorityCode, Mode=TwoWay}"/>

                    <Label Text="Owner" />
                    <userControls:BindablePicker
                        ItemsSource="{Binding Users}"
                        SelectedItem="{Binding Path=Item.OwnerName, Mode=OneWay}"
                        DisplayMemberPath="Name"
                        SelectedValuePath="Id"
                        SelectedValue="{Binding Path=Item.OwnerId, Mode=TwoWay}"/>

                    <Label Text="Due Date" />
                    <DatePicker Date="{Binding Item.DateDue}" />

                    <Label Text="Date Identified" />
                    <DatePicker Date="{Binding Item.DateIdentified}" />

                    <Label Text="Status" />
                    <userControls:BindablePicker
                        ItemsSource="{Binding Statuses}"
                        SelectedItem="{Binding Path=Item.Status, Mode=OneWay}"
                        DisplayMemberPath="Name"
                        SelectedValuePath="Id"
                        SelectedValue="{Binding Path=Item.StatusCode, Mode=TwoWay}"/>


                    <Label Text="Comment" />
                    <Editor Text="{Binding Item.Comment}" HeightRequest="100"/>

                    <Label Text="IOM" />
                    <Entry Text="{Binding Item.OutcomeMeasurementInitial, Mode=TwoWay, Converter={StaticResource DoubleConverter}}" Keyboard="Numeric" />

                    <Label Text="FOM" />
                    <Entry Text="{Binding Item.OutcomeMeasurementFinal, Mode=TwoWay, Converter={StaticResource DoubleConverter}}" Keyboard="Numeric" />

                    <Label Text="Longitude" />
                    <Entry Text="{Binding Item.Longitude, Mode=TwoWay, Converter={StaticResource DoubleConverter}}" Keyboard="Numeric" />

                    <Label Text="Latitude" />
                    <Entry Text="{Binding Item.Latitude, Mode=TwoWay, Converter={StaticResource DoubleConverter}}" Keyboard="Numeric" />

                    <Button Margin="0,20,0,20" Command="{Binding OnSave}" BackgroundColor="{StaticResource Primary}"
                            BorderRadius="2" Text="Save" VerticalOptions="End" TextColor="White" ></Button>

                  </StackLayout>
                </Grid>
            </ScrollView>
            <controls:Loading LoadingIndicator="{Binding IsLoading}"></controls:Loading>
        </Grid>
      </ContentPage.Content>
</ContentPage>

ItemCreatePage.xaml.cs

[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class ItemCreatePage : ContentPage
{
    public ItemCreatePage ()
    {
        InitializeComponent ();
    }

    protected override async void OnAppearing()
    {
        var vm = BindingContext as ItemCreateViewModel;
        vm.Item = new Data.Entities.Item();
        await vm?.GetDeviceLocation();
        base.OnAppearing();
    }
}

The view model code:

public class ItemCreateViewModel : FormViewModel<Data.Entities.Item>
{
    public async Task GetDeviceLocation()
    {
        this.Item = await this.Item.AddDeviceLocation();
        OnPropertyChanged(nameof(this.Item));
    }

    public ILookupService LookupService { get; set; }

    public IItemService ItemService { get; set; }

    #region selectLists
    public List<EnumListItem<ItemPriority>> Priorities => EnumExtensions.ToEnumList<ItemPriority>();

    public List<EnumListItem<ItemStatus>> Statuses => EnumExtensions.ToEnumList<ItemStatus>();

    public string PageTitle => $"{PageTitles.ItemCreate}{this.OfflineStatus}";

    public List<Data.Entities.User> Users => UserService.GetAll(this.Offline);

    public List<Data.Entities.Lookup> Categories => LookupService.GetLookups(this.Offline, LookupTypeCode.ItemCategories);
    #endregion

    public Data.Entities.Item Item { get; set; }

    public ICommand OnSave => new Command(async () =>
    {
        await Loading(CreateItem);
    });

    private async Task CreateItem()
    {
        // ... Save logic is here
    }

FormViewModel:

public class FormViewModel<T> : BaseViewModel
{
    public IValidator<T> Validator => Resolve<IValidator<T>>();

    public bool IsLoading { get; set; }

    /// <summary>
    /// Render a loading spinner whilst we process a request
    /// </summary>
    /// <param name="method"></param>
    /// <returns></returns>
    public async Task Loading(Func<Task> method)
    {
        IsLoading = true;
        await method.Invoke();
        IsLoading = false;
    }
}

BaseViewModel:

public class BaseViewModel : IViewModelBase
{
    public BaseViewModel()
    {
        if (this.GetCurrentUserToken() != null && !UserService.IsActive())
        {
            SettingService.ClearToken();
            Bootstrapper.MasterDetailPage.IsPresented = false;
            Application.Current.MainPage = new LoginPage();
        }
    }

    public T Resolve<T>() => AutofacBootstrapper.Container.Resolve<T>();

    public IUserService UserService => Resolve<IUserService>();

    public INavigator Navigator => AutofacBootstrapper.Navigator;

    public IDisplayAlertFactory DisplayAlert { get; set; }

    public INavigation MasterNavigation => Bootstrapper.MasterDetailPage?.Detail?.Navigation;

    public bool Offline => SettingService.GetSetting<bool>(CacheProperties.Offline);

    public string OfflineStatus => this.Offline ? " - Offline" : string.Empty;

    public Token GetCurrentUserToken() => SettingService.GetToken() ?? null;

    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyname = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyname));
    }
}

Upvotes: 0

Views: 558

Answers (2)

VahidShir
VahidShir

Reputation: 2106

You don't need to set your custom control's BindingContext here:

public Loading()
    {
        InitializeComponent();
        BindingContext = this;//It's wrong!
                              //because the custom control's BindingContext will
                              //automatically be set to the BindingContext of
                              //the page where it's used which is what we usually want.
    }

Here is a way to achieve what you want:

Your Custom Control's XAML:

<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             mc:Ignorable="d"
             x:Class="Framework.Controls.Loading" x:Name="LoadingControl"
             HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
  <ContentView.Content>
      <ActivityIndicator x:Name="TheIndicator" HorizontalOptions="CenterAndExpand"
                         VerticalOptions="CenterAndExpand" Color="DarkBlue"/>
    </ContentView.Content>
</ContentView>

And here is its code-behind:

[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class Loading : ContentView
{
    public static readonly BindableProperty LoadingIndicatorProperty =
        BindableProperty.Create(propertyName:nameof(LoadingIndicator),
        returnType: typeof(bool), declaringType: typeof(Loading), defaultValue: default(bool),
        defaultBindingMode:BindingMode.Default, propertyChanged:LoadingBindingChanged);

    private static void LoadingBindingChanged(BindableObject bindable, object oldvalue, object newvalue)
    {
        var view = (Loading)(bindable);
        view.SetLoadingVisibility((bool)newvalue);
    }

    public Loading()
    {
        InitializeComponent();
        IsVisible = false; // we do this because by default a view' IsVisible property is true
    }

    public bool LoadingIndicator
    {
        get => (bool)GetValue(LoadingIndicatorProperty);
        set => SetValue(LoadingIndicatorProperty, value);
    }

    public void SetLoadingVisibility(bool show)
    {
        IsVisible = show;
        TheIndicator.IsVisible = show;
        TheIndicator.IsRunning = show;
    }
}

Upvotes: 1

lawiluk
lawiluk

Reputation: 597

You are not invoking PropertyChanged event when you change IsLoading property. If you want UI to refresh you need to invoke this event for the chosen property.

Change implementation of IsLoading property to:

private bool _isLoading;
public bool IsLoading
{ 
   get=> _isLoading;
   set
   {
      _isLoading=value;
      OnPropertyChanged(nameof(IsLoading));
   }
}
and it should work

Upvotes: 1

Related Questions