ilivit
ilivit

Reputation: 61

WPF DataGrid bind to ItemsSource items property

I have the collection implementing IList which asynchronously loads data from server. When the collection is accessed by index it returns a stub object and starts the data request in background. After the request has finished collection refreshes inner state and invokes PropertyChanged event. For now the items returned by the collection looks like this:

public class VirtualCollectionItem 
{
    // actual entity
    public object Item { get; } 

    // some metadata properties
    public bool IsLoading { get; } 
}

The problem is that I couldn't realize how to bind such collection to DataGrid. I want somehow set the DataGrid.ItemsSource to the collection of VirtualCollectionItem, make DataGrid show the actual items (in SelectedItem too) and leave the possibility to use metadata ( i.e. use IsLoading to visualize data loading). I've tried to set DataGridRow.Item binding in DataGrid.RowStyle but that haven't worked.

<DataGrid.RowStyle>
  <Style TargetType="{x:Type DataGridRow}">
    <Setter Property="Item" Value="{Binding Item}" />
    <Style.Triggers>
      <DataTrigger Binding="{Binding IsLoading}" Value="True">
        <DataTrigger.Setters>
          <Setter Property="Background" Value="Gray" />
        </DataTrigger.Setters>
      </DataTrigger>
    </Style.Triggers>
  </Style>
</DataGrid.RowStyle>

Another option is to flatten VirtualCollectionItem properties into VirtualCollection itself:

class VirtualCollection
{
    // ...

    // wrapper around VirtualCollectionItem
    public IList<object> Items { get; }

    public IList<bool> IsLoadingItems { get; } 

    // ...
}

and use these properties in DataGrid, but I have not realize how to make this working.

Upvotes: 1

Views: 5580

Answers (3)

ilivit
ilivit

Reputation: 61

I decided to wrap the DataGrid with the ViewModel:

public class DataGridAsyncViewModel : Notifier
{
  public VirtualCollection ItemsProvider { get; }

  private VirtualCollectionItem _selectedGridItem;

  public VirtualCollectionItem SelectedGridItem
  {
    get { return _selectedGridItem; }
    set { Set(ref _selectedGridItem, value); }
  }

  public object SelectedItem => SelectedGridItem?.IsLoading == false ? SelectedGridItem?.Item : null;

  public DataGridAsyncViewModel([NotNull] VirtualCollection itemsProvider)
  {
    if (itemsProvider == null) throw new ArgumentNullException(nameof(itemsProvider));
    ItemsProvider = itemsProvider;
  }
}

And bound it to the DataGrid:

<DataGrid DataContext="{Binding DataGridViewModel}" 
          SelectedItem="{Binding SelectedGridItem}" 
          ItemsSource="{Binding ItemsProvider}" >
  <DataGrid.RowStyle>
    <Style TargetType="{x:Type DataGridRow}">
      <Style.Triggers>
        <DataTrigger Binding="{Binding IsLoading}" Value="True">
          <Setter Property="Background" Value="LightGray" />
        </DataTrigger>
      </Style.Triggers>
    </Style>
  </DataGrid.RowStyle>
  <DataGrid.Columns>
    <DataGridTextColumn Header="..." Binding="{Binding Item.SomeValue}" />
  </DataGrid.Columns>
</DataGrid>

Upvotes: 1

mechanic
mechanic

Reputation: 781

All right, so you load the entities from the server, but you still need to access the collection from the ViewModel. Let's move this functionality to a service. The service allows you to asynchronously load a list of entities' IDs, or load the particular entity details:

using AsyncLoadingCollection.DTO;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public class PeopleService
{
    private List<PersonDTO> _people;

    public PeopleService()
    {
        InitializePeople();
    }

    public async Task<IList<int>> GetIds()
    {
        // simulate async loading delay
        await Task.Delay(1000);
        var result = _people.Select(p => p.Id).ToList();
        return result;
    }
    public async Task<PersonDTO> GetPersonDetail(int id)
    {
        // simulate async loading delay
        await Task.Delay(3000);
        var person = _people.Where(p => p.Id == id).First();
        return person;
    }
    private void InitializePeople()
    {
        // poor person's database
        _people = new List<PersonDTO>();
        _people.Add(new PersonDTO { Name = "Homer", Age = 39, Id = 1 });
        _people.Add(new PersonDTO { Name = "Marge", Age = 37, Id = 2 });
        _people.Add(new PersonDTO { Name = "Bart", Age = 12, Id = 3 });
        _people.Add(new PersonDTO { Name = "Lisa", Age = 10, Id = 4 });
    }
}

The GetPersonDetail method returns a DTO:

public class PersonDTO
{
    public string Name { get; set; }
    public int Age { get; set; }

    public int Id { get; set; }
}

That DTO can be converted to an object required by your ViewModel (and I use Prism as the MVVM framework):

using Prism.Mvvm;

public class Person : BindableBase
{
    private string _name;
    public string Name
    {
        get { return _name; }
        set { SetProperty(ref _name, value); }
    }

    private int _age;
    public int Age
    {
        get { return _age; }
        set { SetProperty(ref _age, value); }
    }

    private int _id;
    public int Id
    {
        get { return _id; }
        set { SetProperty(ref _id, value); }
    }

    private bool _isLoaded;
    public bool IsLoaded
    {
        get { return _isLoaded; }
        set { SetProperty(ref _isLoaded, value); }
    }
}

You can convert the DTO object to Model like this:

using DTO;
using Model;

// we might use AutoMapper instead
public static class PersonConverter
{
    public static Person ToModel(this PersonDTO dto)
    {
        Person result = new Person
        {
            Id = dto.Id,
            Name = dto.Name,
            Age = dto.Age
        };
        return result;
    }
}

And this is how we define commands (that use the item retrieval service) in our ViewModel:

using Helpers;
using Model;
using Prism.Commands;
using Prism.Mvvm;
using Services;
using System.Collections.ObjectModel;
using System.Linq;

public class MainWindowViewModel : BindableBase
{
    #region Fields
    private  PeopleService _peopleService;
    #endregion // Fields

    #region Constructors
    public MainWindowViewModel()
    {
        // we might use dependency injection instead
        _peopleService = new PeopleService();

        People = new ObservableCollection<Person>();
        LoadListCommand = new DelegateCommand(LoadList);
        LoadPersonDetailsCommand = new DelegateCommand(LoadPersonDetails, CanLoadPersonDetails)
            .ObservesProperty(() => CurrentPerson)
            .ObservesProperty(() => IsBusy);
    }
    #endregion // Constructors

    #region Properties

    private string _title = "Prism Unity Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    private Person _currentPerson;
    public Person CurrentPerson
    {
        get { return _currentPerson; }
        set { SetProperty(ref _currentPerson, value); }
    }

    private bool _isBusy;
    public bool IsBusy
    {
        get { return _isBusy; }
        set { SetProperty(ref _isBusy, value); }
    }
    public ObservableCollection<Person> People { get; private set; }

    #endregion // Properties

    #region Commands

    public DelegateCommand LoadListCommand { get; private set; }
    private async void LoadList()
    {
        // reset the collection
        People.Clear();

        var ids = await _peopleService.GetIds();
        var peopleListStub = ids.Select(i => new Person { Id = i, IsLoaded = false, Name = "No details" });

        People.AddRange(peopleListStub);
    }

    public DelegateCommand LoadPersonDetailsCommand { get; private set; }
    private bool CanLoadPersonDetails()
    {
        return ((CurrentPerson != null) && !IsBusy);
    }
    private async void LoadPersonDetails()
    {
        IsBusy = true;

        var personDTO = await _peopleService.GetPersonDetail(CurrentPerson.Id);
        var updatedPerson = personDTO.ToModel();
        updatedPerson.IsLoaded = true;

        var oldPersonIndex = People.IndexOf(CurrentPerson);
        People.RemoveAt(oldPersonIndex);
        People.Insert(oldPersonIndex, updatedPerson);
        CurrentPerson = updatedPerson;

        IsBusy = false;
    }

    #endregion // Commands
}

And finally, the View may be as simple as this:

<Window x:Class="AsyncLoadingCollection.Views.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:prism="http://prismlibrary.com/"
    Title="{Binding Title}"
    Width="525"
    Height="350"
    prism:ViewModelLocator.AutoWireViewModel="True">
<StackPanel>
    <!--<ContentControl prism:RegionManager.RegionName="ContentRegion" />-->
    <StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
        <Button Width="100"
                Margin="10"
                Command="{Binding LoadListCommand}"
                Content="Load List" />
        <Button Width="100"
                Margin="10"
                Command="{Binding LoadPersonDetailsCommand}"
                Content="Load Details" />
    </StackPanel>
    <TextBlock Text="{Binding CurrentPerson.Name}" />
    <DataGrid CanUserAddRows="False"
              CanUserDeleteRows="False"
              ItemsSource="{Binding People}"
              SelectedItem="{Binding CurrentPerson,
                                     Mode=TwoWay}">
        <DataGrid.RowStyle>
            <Style TargetType="{x:Type DataGridRow}">
                <!--<Setter Property="Item" Value="{Binding Item}" />-->
                <Style.Triggers>
                    <DataTrigger Binding="{Binding IsLoaded}" Value="False">
                        <DataTrigger.Setters>
                            <Setter Property="Background" Value="DarkGray" />
                        </DataTrigger.Setters>
                    </DataTrigger>
                </Style.Triggers>
            </Style>
        </DataGrid.RowStyle>
    </DataGrid>
</StackPanel>
</Window>

Upvotes: 1

D&#225;vid Moln&#225;r
D&#225;vid Moln&#225;r

Reputation: 11533

You can bind the VirtualCollection directly to the DataGrid.ItemsSource property. Then bind the SelectedItem property too:

<DataGrid ItemsSource="{Binding MyVirtualCollectionList}" SelectedItem={Binding SelectedItem, Mode=TwoWay} />

Then the ViewModel:

public class MyViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

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

    private VirtualCollection _myVirtualCollectionList;

    public VirtualCollection MyVirtualCollectionList
    {
        get { return _myVirtualCollectionList; }
        set
        {
            _myVirtualCollectionList = value;
            OnPropertyChanged();
        }
    }

    private VirtualCollectionItem _selectedItem;

    public VirtualCollectionItem SelectedItem
    {
        get { return _selectedItem; }
        set
        {
            _selectedItem = value;
            OnPropertyChanged();
        }
    }
}

You have to fire the OnPropertyChanged event after loading the list and you have to do it from the MyViewModel object (Which I think you do not do)! You could use an ObservableCollection too (you can extend it). Then you do not need the OnPropertyChange event. Adding/removing items from the collection automatically notifies the UI.

The SelectedItem is working independently to the list. In the DataTemplate for a row you will use {Binding IsLoading} and {Binding Item.SomeProperty}.

Upvotes: 0

Related Questions