Search code examples
c#.netxamlmauicollectionview

with .net maui, when creating a CollectionView from a List, how do I access another List to pull details based on ID?


I'm still fairly new to xaml and .net maui but I don't really know what search terms to even use. I want to list today's task notifications in xaml with a collection view. I want this list, when displaying in xaml collection view, to pull details from another list using the TaskID as an identifier key.

For example, roughly:

    class Notification:
       int Id;
       int TaskID;
       int Title;
       DateTime NotificationTime;

    class Task:
       int Id;
       string Name;
       string Details;
    UpcomingNotificationsViewModel:
    
    List<Notification> NotificationsList { get; set; }
    List<Task> TasksList { get; set; }

    async void LoadData()
    {
        var result = from s in NotificationsList
              where s.NotificationTime.Date == today
              group s by s.NotificationTime into g
              select new GroupedNotification{ NotificationTime = g.Key, Notification = g.ToList() };

        GroupedNotifications = result;
    }


    public class GroupedNotification
    {
        public DateTime NotifyTime { get; set; }
        public List<Notification> Notification { get; set; }
    }

In the XAML, while iterating over CollectionView of Notifications, I want to also pull data from the TasksList like the Task.Name, Task.Details, and so on.

    <CollectionView ItemsSource="{Binding GroupedNotifications}">
    <CollectionView.ItemTemplate>
        <DataTemplate>
            <VerticalStackLayout>
                <!-- Display the group header (time) -->
                <Label Text="{Binding NotifyTime, StringFormat='{}{0:h\\:mm tt}'}"
        FontAttributes="Bold"
        TextColor="Blue"/>

            <!-- Display the notifications within the group -->
            <CollectionView ItemsSource="{Binding Notification}">
                <CollectionView.ItemTemplate>
                    <DataTemplate>
                            <Grid Padding="0,5">
                                <Frame Padding="10,10">
                                    <StackLayout>
                                        <Label Text="{Binding Title}" />
                                        <Label Text="{Binding NotificationTime}" />
//this label below, how can I link it to TaskList and get details based on TaskID from this Notification List?
                                        <Label Text="{Binding How can I link this to show Task details based on the ID??}"   />
                                        <!-- Other properties... -->
                                    </StackLayout>
                                </Frame>
                            </Grid>
                        </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
            </VerticalStackLayout>
        </DataTemplate>
    </CollectionView.ItemTemplate>
    </CollectionView>`

I tried several different iterations of this code, I tried searching for the past three hours, tried asking ChatGPT and Gemini but I cannot find what I'm looking for. I need someone to point me in the correct direction.


Solution

  • As I understand it, you are displaying some daily tasks in a collection view, and want to query the database for task details based on some event (for example, tapping the task line). There are many ways to do this, but I hope to "point you in the right direction" as you said. Two things to know in advance: For SQLite I'm specifically using the sqlite-pcl-net NuGet for this sample. Also, to try and avoid this answer going even longer, I put the full code on GitHub to browse or clone.


    task list with details


    In this version, there are two tables in the SQLite database, corresponding to a TaskItem record class for the parent item and a DetailItem record class for its subitems. For the task item, the data template that will host it in the xaml will attach a TapGestureRecognizer to the label displaying the task description, which will call the LabelTappedCommand in the view model. In that method, the database will be queried for detail items with a ParentId value equal to the TaskItem that has been tapped. Once the detail items are retrieved, one approach would be to use shell navigation to display the details in an entirely different view, or alternatively stay on the main page and use OnePage navigation to hide the task grid and show the details grid as is the case here.


    class TaskItem : INotifyPropertyChanged
    {
        public TaskItem()
        {
            LabelTappedCommand = new Command(OnLabelTapped);
        }
        public ICommand LabelTappedCommand { get; private set; }
        private void OnLabelTapped(object o)
        {
            using (var database = new SQLiteConnection(DatabasePath))
            using(DHostLoading.GetToken())
            {
                var sql = $"select * from {nameof(DetailItem)} where {nameof(DetailItem.ParentId)} = '{Id}'";
                Details.Clear();
                foreach (var detail in database.Query<DetailItem>(sql))
                {
                    Details.Add(detail);
                }
                new OnePageStateRequestArgs(OnePageState.Detail).FireSelf(this);
            }
        }
        [PrimaryKey]
        public string Id { get; set; } = $"{Guid.NewGuid()}";
        public DateTime DateTime { get; set; }
        public string Description
        {
            get => _description;
            set
            {
                if (!Equals(_description, value))
                {
                    _description = value;
                    OnPropertyChanged();
                }
            }
        }
        string _description = string.Empty;
    
        protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        public event PropertyChangedEventHandler? PropertyChanged;
    
        public override string ToString() => Description ?? string.Empty;
    
        public ObservableCollection<DetailItem> Details { get; } = 
            new ObservableCollection<DetailItem>();
    }
    

    The DetailItem is expecting to be hosted in a data template that has a checkbox to indicate whether the task has been completed, and when the user changes this value it will be committed to the database. A word of caution would be that having the Done property directly bound to the database update could be problematic when the property is changing as a result of a query that is loading it. In fact, the database is likely to hang. (This kind of circularity is a common issue when loading objects with persisted properties.) To prevent this, this sample uses a reference-counted semaphore that is checked out before the query is made.

    class DetailItem : INotifyPropertyChanged
    {
        [PrimaryKey]
        public string Id { get; set; } = $"{Guid.NewGuid()}";
        public string? ParentId { get; set; }
        public string Description
        {
            get => _description;
            set
            {
                if (!Equals(_description, value))
                {
                    _description = value;
                    OnPropertyChanged();
                }
            }
        }
        string _description = string.Empty;
    
        public bool Done
        {
            get => _done;
            set
            {
                if (!Equals(_done, value))
                {
                    _done = value;
                    OnPropertyChanged();
                    if (DHostLoading.IsZero())
                    {
                        using (var database = new SQLiteConnection(DatabasePath))
                        {
                            var sql = $"update {nameof(DetailItem)} set {nameof(DetailItem.Done)} = {Done} where {nameof(Id)} = '{Id}'";
                            database.Execute(sql);
                        }
                    }
                }
            }
        }
        bool _done = default;
    
        public override string ToString() => Description ?? string.Empty;
        protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
        public event PropertyChangedEventHandler? PropertyChanged;
    }
    

    Main Page

    In the screenshot shown above, the top level collection view isn't showing TaskItem or DetailItem objects directly. For demonstration purposes, the main view consists of seven Card objects representing today, tomorrow, and the other five days in the week to come.

    class Card : INotifyPropertyChanged
    {
        public Card(DateTime dt) : this() => DateTime = dt;
        public Card() { }
    
        [PrimaryKey]
        public string Id { get; set; } = $"{Guid.NewGuid()}";
        public string? Description => 
            DateTime.Equals(DateTime.Today) ? "Today" :
            DateTime.Equals(DateTime.Today.AddDays(1)) ? "Tomorrow" :
            DateTime.DayOfWeek.ToString();
    
        public ObservableCollection<TaskItem> TaskItems { get; } = new ObservableCollection<TaskItem>();
    
        public DateTime DateTime { get; set; }
    
        protected virtual void OnPropertyChanged([CallerMemberName]string? propertyName = null) => 
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        public event PropertyChangedEventHandler? PropertyChanged;
    }
    

    The view can be refreshed by executing the RefreshCommand in the main view model, and (for example) it is always invoked in response to the Appearing) event.

    public partial class MainPage : ContentPage
    {
        public MainPage() => InitializeComponent();
    
        new MainPageBindingContext BindingContext =>
            (MainPageBindingContext)base.BindingContext;
    
        protected override void OnAppearing()
        {
            base.OnAppearing();
            BindingContext.RefreshCommand.Execute(null);
        }
    }
    

    The main page is one-time populated with a list of seven cards, and when refreshed this list is iterated. A query is made on each card for tasks with matching day, and these are added to the TaskItems collection of the card.

    class MainPageBindingContext : INotifyPropertyChanged
    {
        public ObservableCollection<Card> Days { get; } = new ObservableCollection<Card>
        {
            new Card(DateTime.Today),
            new Card(DateTime.Today.AddDays(1)),
            new Card(DateTime.Today.AddDays(2)),
            new Card(DateTime.Today.AddDays(3)),
            new Card(DateTime.Today.AddDays(4)),
            new Card(DateTime.Today.AddDays(5)),
            new Card(DateTime.Today.AddDays(6)),
        };
    
        // <PackageReference Include="IVSoftware.Portable.Disposable" Version="1.2.0" />
        // This is to suppress sqlite updates while the object is loading.
        public static DisposableHost DHostLoading { get; } = new DisposableHost();
    
        public ICommand RefreshCommand { get; private set; }
        private void onRefresh(object o)
        {
            using (var database = new SQLiteConnection(DatabasePath))
            {
                foreach (var day in Days)
                {
                    var sql = day.DateTime.ToDateOnlyQuery(nameof(TaskItem));
                    foreach (var taskItem in database.Query<TaskItem>(sql))
                    {
                        day.TaskItems.Add(taskItem);
                    }
                }
            }
        }
        .
        .
        .
    }
    static partial class Extensions
    {
        public static string ToDateOnlyQuery(this DateTime date, string table) =>
            $"SELECT * FROM {table} WHERE DateTime >= {date.Date.Ticks} AND DateTime < {date.Date.AddDays(1).Ticks}";
    }
    

    The xaml features a responsive layout that varies the height of the cards depending on the number of tasks present in the day.

    Xaml

    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 xmlns:local ="clr-namespace:VariableSubitemsPOC"
                 x:Class="VariableSubitemsPOC.MainPage"
                 Shell.NavBarIsVisible="{
                    Binding OnePageState, 
                    Converter={StaticResource EnumToBoolConverter}, 
                    ConverterParameter={x:Static local:OnePageState.Main}}">
        <ContentPage.BindingContext>
            <local:MainPageBindingContext />
        </ContentPage.BindingContext>
        <ContentPage.Resources>
            <ResourceDictionary>
                <local:EnumToBoolConverter x:Key="EnumToBoolConverter"/>
            </ResourceDictionary>
        </ContentPage.Resources>
        <Grid>
            <Grid
                IsVisible="{
                    Binding OnePageState, 
                    Converter={StaticResource EnumToBoolConverter}, 
                    ConverterParameter={x:Static local:OnePageState.Main}}"
                Padding="30,0" 
                RowDefinitions="70, *">
                <Image
                Source="dotnet_bot.png"
                HeightRequest="70"
                Aspect="AspectFit"
                VerticalOptions="Center"
                SemanticProperties.Description="dot net bot in a race car number eight" />
                <CollectionView 
                Grid.Row="1"
                ItemsSource="{Binding Days}" 
                BackgroundColor="Azure">
                    <CollectionView.ItemTemplate>
                        <DataTemplate>
                            <Frame
                                Padding="10"
                                Margin="5"
                                BorderColor="Gray"
                                CornerRadius="10"
                                HasShadow="True">
                                <StackLayout>
                                    <Label 
                                        Text="{Binding Description}" 
                                        FontAttributes="Bold"
                                        FontSize="Medium"
                                        HorizontalOptions="Fill"
                                        HorizontalTextAlignment="Start"
                                        VerticalTextAlignment="Center"/>
                                    <StackLayout>
                                        <StackLayout 
                                            BindableLayout.ItemsSource="{Binding TaskItems}">
                                            <BindableLayout.ItemTemplate>
                                                <DataTemplate>
                                                    <Label 
                                                    Text="{Binding Description}" 
                                                    FontSize="Small" 
                                                    Margin="2,2">
                                                        <Label.GestureRecognizers>
                                                            <TapGestureRecognizer Command="{Binding LabelTappedCommand}"/>
                                                        </Label.GestureRecognizers>
                                                    </Label>
                                                </DataTemplate>
                                            </BindableLayout.ItemTemplate>
                                        </StackLayout>
                                    </StackLayout>
                                </StackLayout>
                            </Frame>
                        </DataTemplate>
                    </CollectionView.ItemTemplate>
                </CollectionView>
            </Grid>
            .
            .
            .
    
        </Grid>
    </ContentPage>