0

Background

  • I've got some binding issues that I can not for the life of me resolve. I've spent nearly 3 days working on this, but I'm officially lost so I'm hoping someone can point me in the right direction.
  • This is part of a MAUI project that I have setup to display a collection of objects in different views. These objects are directly mapped to SQL table definitions, and each instance of these objects are created by making REST calls to an API which integrates to the SQL server.
  • To keep the code repetition down and help keep this somewhat maintainable, I've setup two different base types to represent the ContentViews for each SQL object.
    • To display a single object, I create a new instance of SqlObjectView<TObjectType> which is inherited by the different object views. For example, the UserAccountView just takes the type of UserAccount in the generic parameter and the XAML varies for each object based on it's properties.
    • To display a collection of objects I create a new instance of SqlCollectionView<TObjectType, TParentType> where TObjectType is the type of object I'm holding a collection of, and TParentType is the type of object I'm getting this collection from.
  • It is my first time dealing with MAUI, but I've got a good bit of experience with WPF binding. Although, from what I've seen, MAUI seems to be an entirely different animal.
  • Please forgive me for any massive fundamental issues that I've overlooked here. I'm still trying to wrap my head around this whole BindingContext concept.

The Problem

  • Inside my main ContentPage, I've got a grid with several child controls (ContentView) which are all bound directly to a UserAccount instance.
  • This instance is named SessionUser. The UserAccount property is setup to fire a PropertyChanged event when the value is changed for it. (This is likely where my issue begins)
  • Each child control inside the grid on my ContentPage that binds to the SessionUser property is updating/binding perfectly fine the first time I set it, but anytime I set it after the initial binding does not update the controls.

My Question

  • How can I either force refresh the binding for the SessionUser property to make the view update accordingly? If that's not possible, why?
  • Is there a way I can hook onto the INotifyPropertyChanged event handler on the UserAccount definition to force fire a PropertyChanged event when something in the SessionUser is updated?
  • Why is this happening? I have a feeling it's got something to do with my BindingContext or the fact that I'm using a normal CLR property for the SessionUser property instead of a BindableProperty. But even when I tried setting it up that way, the issue persisted

The Code In Question

  • I've tried to include only the relevant code here, but if there's something missing that may help clear things up, I can provide it.
  • As I mentioned before, the bindings work perfectly the first time they're set, but as properties on the SessionUser property are changed, I am not able to catch that PropertyChanged event.
  • There's also going to be a lot of missing EventHandlers/Methods in the xaml.cs snippets since most of it has no relevance to this issue.

SpeedDooMainPage.xaml and SpeedDooMainPage.xaml.cs

  • SpeedDooMainPage.xaml - (ContentPage)
    <?xml version="1.0" encoding="utf-8"?>
    <ContentPage x:Class="SpeedDooUtility.SpeedDooMainPage"
        xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
        xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
        xmlns:converters="using:SpeedDooUtility.ViewContent.Converters"
        xmlns:notifications="clr-namespace:SpeedDooUtility.ViewContent.Notifications"
        xmlns:sqlObjects="using:SpeedDooUtility.ViewContent.SqlModelViews.SqlObjects"
        xmlns:sqlCollections="using:SpeedDooUtility.ViewContent.SqlModelViews.SqlCollections"
        BindingContext="{Binding Source={RelativeSource Self}}">
    
        <!-- Main Content Resources -->
        <ContentPage.Resources>
            <!-- ... -->
        </ContentPage.Resources>
    
        <!-- Main Page Content -->
        <Grid VerticalOptions="Fill" HorizontalOptions="Fill" Margin="0">
    
            <!-- User Login Content -->
            <Border Style="{StaticResource ContentWrappingBorder}" VerticalOptions="Center"
                HorizontalOptions="Center"
                IsVisible="{Binding IsUserLoggedIn, Converter={StaticResource BooleanConverter}, ConverterParameter=true}">
                <VerticalStackLayout VerticalOptions="Center" HorizontalOptions="Center" Spacing="15"
                    Padding="50">
                    <!-- ... -->
                </VerticalStackLayout>
            </Border>
    
            <!-- Main Page Content -->
            <Grid VerticalOptions="Fill" HorizontalOptions="Fill"
                IsVisible="{Binding Path=IsUserLoggedIn, Converter={StaticResource BooleanConverter}, ConverterParameter=false}">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width=".65*" />
                    <ColumnDefinition />
                </Grid.ColumnDefinitions>
                <Border Grid.Column="0" Style="{StaticResource ContentWrappingBorder}" Margin="5">
                    <Grid Margin="5">
                        <Grid.RowDefinitions>
                            <RowDefinition Height="45" />
                            <RowDefinition Height="2.15*" />
                            <RowDefinition />
                        </Grid.RowDefinitions>
                        <Label Text="User And Vehicle Information"
                            Style="{StaticResource TitleTextStyle}" />
    
                        <Grid Grid.Row="1" Margin="2">
                            <Grid.RowDefinitions>
                                <RowDefinition />
                                <RowDefinition Height=".685*" />
                            </Grid.RowDefinitions>
                            <Border Grid.Row="0" Style="{StaticResource SqlObjectBorder}">
                                <ScrollView>
                                    <sqlObjects:UserAccountView
                                        Padding="5,0,0,0"
                                        SqlObjectInstance="{Binding Path=SessionUser}"
                                        IsDeveloperMode="{Binding Path=IsDeveloperMode}" />
                                </ScrollView>
                            </Border>
                            <Border Grid.Row="1" Style="{StaticResource SqlObjectBorder}">
                                <ScrollView>
                                    <sqlCollections:AuthTokenCollectionView
                                        ParentSqlInstance="{Binding Path=SessionUser}"
                                        IsDeveloperMode="{Binding Path=IsDeveloperMode}" />
                                </ScrollView>
                            </Border>
                        </Grid>
    
                        <Grid Grid.Row="2" Margin="2">
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition />
                                <ColumnDefinition />
                            </Grid.ColumnDefinitions>
                            <Border Grid.Column="0" Style="{StaticResource SqlObjectBorder}">
                                <ScrollView>
                                    <sqlCollections:VehicleCollectionView
                                        x:Name="scvVehicles"
                                        ParentSqlInstance="{Binding Path=SessionUser}"
                                        IsDeveloperMode="{Binding Path=IsDeveloperMode}" />
                                </ScrollView>
                            </Border>
                            <Border Grid.Column="1" Style="{StaticResource SqlObjectBorder}">
                                <ScrollView>
                                    <sqlCollections:FlashHistoryCollectionView
                                        IsDeveloperMode="{Binding Path=IsDeveloperMode}"
                                        ParentSqlInstance="{Binding Source={x:Reference scvVehicles}, Path=SelectedSqlInstance}" />
                                </ScrollView>
                            </Border>
                        </Grid>
                    </Grid>
                </Border>
    
                <!-- Flashing Controls -->
                <Border Grid.Column="1" Style="{StaticResource ContentWrappingBorder}" Margin="5">
                    <!-- ... -->
                </Border>
            </Grid>
            <notifications:ProgressBanner x:Name="ProgressBannerTop" BannerHeight="125"
                Location="BannerTop" VerticalOptions="Start" />
            <notifications:NotificationBanner x:Name="NotiBannerTop" BannerHeight="125"
                Location="BannerTop" VerticalOptions="Start" />
        </Grid>
    </ContentPage>
    
  • SpeedDooMainPage.xaml.cs - (ContentPage)
    namespace SpeedDooUtility
    {
        public partial class SpeedDooMainPage : ContentPage
        {
            #region Custom Events
    
            // Custom events for processing specific actions/operations during execution
            private readonly EventHandler<UserAccount> UserLoggedIn;
            private void OnUserLoggedIn(object SendingObject, UserAccount LoggedInUser)
            {        
                // Invoke the logged in event instance here and hide our UserLoginView page content
                this.IsUserLoggedIn = true;
                this.SessionUser = LoggedInUser;
            }
    
            protected bool SetField<T>(ref T Field, T FieldValue, [CallerMemberName] string PropertyName = null)
            {
                // See if we need to fire a new event for property changed or not
                if (EqualityComparer<T>.Default.Equals(Field, FieldValue)) return false;
    
                // Update our field value and fire our property changed event as needed
                Field = FieldValue;
                OnPropertyChanged(PropertyName);
                return true;
            }
    
            #endregion //Custom Events
    
            #region Fields
    
            // Private backing fields for display/window configuration
            private bool _isLoginReady;                         // Tells us if we're ready to login
            private bool _isUserLoggedIn;                       // Sets if we're logged in or not
            private bool _isDeveloperMode;                      // Sets if we're in developer mode or not
            private UserAccount _sessionUser;                   // The currently logged in user account for this application
    
            #endregion //Fields
    
            #region Properties
    
            // Public facing properties for the display/window configuration
            public bool IsLoginReady
            {
                get => _isLoginReady;
                private set => SetField(ref _isLoginReady, value);
            }
            public bool IsUserLoggedIn
            {
                get => _isUserLoggedIn;
                private set => SetField(ref _isUserLoggedIn, value);
            }
            public bool IsDeveloperMode
            {
                get => _isDeveloperMode;
                init => SetField(ref _isDeveloperMode, value);
            }
            public UserAccount SessionUser
            {
                get => _sessionUser;
                private set => SetField(ref _sessionUser, value);
            }
    
            #endregion //Properties
    
            #region Structs and Classes
            #endregion //Structs and Classes
    
            // ------------------------------------------------------------------------------------------------------------------------------------------
    
            public SpeedDooMainPage()
            {
                // InitializeComponent and show this page content
                InitializeComponent();
                this.UserLoggedIn += this.OnUserLoggedIn;
            }
    
            // ------------------------------------------------------------------------------------------------------------------------------------------
    
            private async void LoginUserButton_OnClicked(object SendingControl, EventArgs EventArgs)
            {
                try
                {
                    // If the login is not ready return out
                    if (!IsLoginReady) return;
    
                    // Disable the sending button
                    Button SendingButton = (Button)SendingControl;
                    SendingButton.IsEnabled = false;
    
                    // Execute the login routine on a background thread to keep our UI alive
                    AuthToken LoginToken = null; Exception LoginException = null; UserAccount? UserGenerated = null;
                    if (!await Task.Run(() => this.ExecuteUserLogin(out UserGenerated, out LoginToken, out LoginException)))
                    {
                        // Return out since at this point login failed
                        SendingButton.IsEnabled = true;
                        return;
                    }
    
                    // Invoke the login event on the main window here
                    this.UserLoggedIn.Invoke(this, UserGenerated);
                    SendingButton.IsEnabled = true;
                }
                catch { return; }
            }
            private bool ExecuteUserLogin(out UserAccount? User, out AuthToken? LoginToken, out Exception LoginEx)
            {
                // Don't mind the logic inside this method. There's nothing involving the UI/XAML/BindingContext in here
            }
        }
    }
    

SqlObjectView - (ContentView)

  • SqlObjectView.cs
    namespace SpeedDooUtility.ViewContent.SqlModelViews
    {
        public class SqlObjectView<TObjectType> : ContentView where TObjectType : SqlObject<TObjectType>
        {
            #region Custom Events
    
            private static void OnViewTypeChanged(BindableObject BindableObj, object OldValue, object NewValue) { /* Callback Method */ }
            private static void OnIsDeveloperModeChanged(BindableObject BindableObj, object OldValue, object NewValue) { /* Callback Method */ }
            private static void OnSqlObjectInstanceChanged(BindableObject BindableObj, object OldValue, object NewValue)
            {
                // Store the view and check if the value needs to be updated
                if (BindableObj is not SqlObjectView<TObjectType> SqlObjectViewInstance) return;
                SqlObjectViewInstance.SqlObjectInstance = (TObjectType)NewValue;
                SqlObjectViewInstance.OnPropertyChanged(nameof(SqlObjectInstance));
            }
    
            #endregion //Custom Events
    
            #region Fields
    
            // Public static bindings for property values on the view content
            public static readonly BindableProperty ViewTypeProperty = BindableProperty.Create(
                nameof(ViewType),
                typeof(ViewTypes),
                typeof(SqlObjectView<TObjectType>),
                propertyChanged: OnViewTypeChanged);
    
            public static readonly BindableProperty IsDeveloperModeProperty = BindableProperty.Create(
                nameof(IsDeveloperMode),
                typeof(bool),
                typeof(SqlObjectView<TObjectType>),
                propertyChanged: OnIsDeveloperModeChanged);
    
            public static readonly BindableProperty SqlObjectInstanceProperty = BindableProperty.Create(
                nameof(SqlObjectInstance),
                typeof(TObjectType),
                typeof(SqlObjectView<TObjectType>),
                propertyChanged: OnSqlObjectInstanceChanged);
    
            #endregion //Fields
    
            #region Properties
    
            // Public facing properties for our view content to bind onto
            public ViewTypes ViewType
            {
                get => (ViewTypes)GetValue(ViewTypeProperty);
                set => SetValue(ViewTypeProperty, value);
            }
            public bool IsDeveloperMode
            {
                get => (bool)GetValue(IsDeveloperModeProperty);
                set => SetValue(IsDeveloperModeProperty, value);
            }
            public TObjectType SqlObjectInstance
            {
                get => (TObjectType)GetValue(SqlObjectInstanceProperty);
                set => SetValue(SqlObjectInstanceProperty, value);
            }
    
            #endregion //Properties
    
            #region Structs and Classes
    
            /// <summary>
            /// Enumeration holding our different types of view content for this control
            /// </summary>
            public enum ViewTypes { AuthToken, UserAccount, Vehicle, FlashHistory }
    
            #endregion //Structs and Classes
    
            // ------------------------------------------------------------------------------------------------------------------------------------------
    
            protected SqlObjectView(ViewTypes View) { this.ViewType = View; }
        }
    }
    

SqlObjectCollectionView - (ContentView)

  • SqlObjectCollectionView.cs
    namespace SpeedDooUtility.ViewContent.SqlModelViews
    {
        public class SqlCollectionView<TObjectType, TParentType> : ContentView, INotifyCollectionChanged 
            where TObjectType : SqlObject<TObjectType>
            where TParentType : SqlObject<TParentType>
        {
            #region Custom Events
    
            // Event handler for invoking a new CollectionChanged event
            public event NotifyCollectionChangedEventHandler CollectionChanged;
    
            private static void OnViewTypeChanged(BindableObject BindableObj, object OldValue, object NewValue) { /* Callback Method */ }
            private static void OnIsDeveloperModeChanged(BindableObject BindableObj, object OldValue, object NewValue) { /* Callback Method */ }
            private static void OnParentSqlInstanceChanged(BindableObject BindableObj, object OldValue, object NewValue)
            {
                // Store the view and check if the value needs to be updated
                if (BindableObj is not SqlCollectionView<TObjectType, TParentType> SqlCollectionInstanceView) return;
                SqlCollectionInstanceView.ParentSqlInstance = (TParentType)NewValue;
                SqlCollectionInstanceView.OnPropertyChanged(nameof(ParentSqlInstance));
    
                // Check if we're using vehicle history or not and update flashes if needed
                if (NewValue != null) SqlCollectionInstanceView.RefreshChildInstances();
                else
                {
                    // Clear our list if the vehicle is null and invoke a reset event for the collection
                    SqlCollectionInstanceView.SqlObjectInstances.Clear();
                    SqlCollectionInstanceView.OnCollectionChanged(NotifyCollectionChangedAction.Reset);
                }
            }
            private static void OnSelectedSqlInstanceChanged(BindableObject BindableObj, object OldValue, object NewValue) { /* Callback Method */ }
            private static void OnSqlObjectInstancesChanged(BindableObject BindableObj, object OldValue, object NewValue) { /* Callback Method */ }
    
            // ======================================================================================================================
            // NOTE: INotifyCollectionChanged is implemented, but I've left out those event handlers since they're working correctly
            // ======================================================================================================================
    
            #endregion //Custom Events
    
            #region Fields
    
            // Public static bindings for property values on the view content
            public static readonly BindableProperty ViewTypeProperty = BindableProperty.Create(
                nameof(ViewType),
                typeof(ViewTypes),
                typeof(SqlCollectionView<TObjectType, TParentType>),
                propertyChanged: OnViewTypeChanged);
    
            public static readonly BindableProperty IsDeveloperModeProperty = BindableProperty.Create(
                nameof(IsDeveloperMode),
                typeof(bool),
                typeof(SqlCollectionView<TObjectType, TParentType>),
                propertyChanged: OnIsDeveloperModeChanged);
    
            public static readonly BindableProperty ParentSqlInstanceProperty = BindableProperty.Create(
                nameof(SqlObjectInstances),
                typeof(TParentType),
                typeof(SqlCollectionView<TObjectType, TParentType>),
                propertyChanged: OnParentSqlInstanceChanged);
    
            public static readonly BindableProperty SelectedSqlInstanceProperty = BindableProperty.Create(
                nameof(SqlObjectInstances),
                typeof(TObjectType),
                typeof(SqlCollectionView<TObjectType, TParentType>),
                propertyChanged: OnSelectedSqlInstanceChanged);
    
            public static readonly BindableProperty SqlObjectInstancesProperty = BindableProperty.Create(
                nameof(SqlObjectInstances),
                typeof(ObservableCollection<TObjectType>),
                typeof(SqlCollectionView<TObjectType, TParentType>),
                propertyChanged: OnSqlObjectInstancesChanged);
    
            #endregion //Fields
    
            #region Properties
    
            // Public facing properties for our view content to bind onto
            public ViewTypes ViewType
            {
                get => (ViewTypes)GetValue(ViewTypeProperty);
                set => SetValue(ViewTypeProperty, value);
            }
            public bool IsDeveloperMode
            {
                get => (bool)GetValue(IsDeveloperModeProperty);
                set => SetValue(IsDeveloperModeProperty, value);
            }
            public TParentType ParentSqlInstance
            {
                get => (TParentType)GetValue(ParentSqlInstanceProperty);
                set => SetValue(ParentSqlInstanceProperty, value);
            }
            public TObjectType SelectedSqlInstance
            {
                get => (TObjectType)GetValue(SelectedSqlInstanceProperty);
                set => SetValue(SelectedSqlInstanceProperty, value);
            }
            public ObservableCollection<TObjectType> SqlObjectInstances
            {
                get => (ObservableCollection<TObjectType>)GetValue(SqlObjectInstancesProperty);
                set => SetValue(SqlObjectInstancesProperty, value);
            }
    
            #endregion //Properties
    
            #region Structs and Classes
    
            /// <summary>
            /// Enumeration holding our different types of view content for this control
            /// </summary>
            public enum ViewTypes { Tokens, Vehicles, FlashHistories }
    
            #endregion //Structs and Classes
    
            // ------------------------------------------------------------------------------------------------------------------------------------------
    
            /// <summary>
            /// The base CTOR for a new SqlCollectionView. This configures development mode and sets up
            /// what type of view we're using
            /// </summary>
            /// <param name="View">The type of view we're building</param>
            protected SqlCollectionView(ViewTypes View)
            {
                // Store the view type and build a logger instance
                this.ViewType = View;
                this.SqlObjectInstances ??= new();
            }    
    
            // ------------------------------------------------------------------------------------------------------------------------------------------
    
            public void Add(TObjectType SqlInstance) { /* Adds to the SqlObjectInstances collection */ }
            public void Update(TObjectType SqlInstance, bool AddMissing = false) { /* Updates value in the SqlObjectInstances collection */ }
            public int Remove(TObjectType SqlInstance, bool MatchGUID = false) { /* Removes from the SqlObjectInstances collection */ }  
    
            // ------------------------------------------------------------------------------------------------------------------------------------------
    
            protected void RefreshChildInstances()
            {
                // Find the parent type and make sure we can use it
                Type ParentType = this.ParentSqlInstance.GetType();
                if (ParentType != typeof(UserAccount) && ParentType != typeof(Vehicle))
                    throw new Exception($"Error! Can not use ParentType {ParentType.Name} for a SqlCollectionView!");
    
                // Store and cast our parent object as it's requested type and populate the collection as needed
                IEnumerable<TObjectType> LoadedChildObjects = new List<TObjectType>();
    
                // ==================================================================================================
                // NOTE: There's a big switch block here to store the new values for the LoadedChildObjects list here
                // ==================================================================================================
    
                // Clear our list of objects out and insert all of our new values one by one
                this.SqlObjectInstances.Clear();
                this.OnCollectionChanged(NotifyCollectionChangedAction.Reset);
    
                // Iterate all the child objects and insert them into our collection
                foreach (var ChildObject in LoadedChildObjects) this.Add(ChildObject);
                this.SelectedSqlInstance = this.SqlObjectInstances.FirstOrDefault();
            }
        }
    }
    

So far, I've tried the following things

  • Change the SessionUser property from CLR to BindableProperty.
    • This did not fix the issue. Child properties were still not notifying the owning ContentPage.
    • I did notice I was actually hitting the bindable property, but only once when set the first time.
  • Attach a new callback onto the SessionUser event for PropertyChanged.
    • This causes massive hang-ups in execution. I think it's notifying too often?
    • This also broke my ASP.NET API project somehow. This needs to be investigated more by me.
  • Manually specify the new value for each child control when needed
    • As expected, this does force the bindings on the UI to refresh and the content displays correctly.
    • However, I'd like to do this entirely through BindingContext and properties/events if possible
  • Did you override OnPropertyChanged to see if they are actually changing? And if they are wouldn't that work for you – FreakyAli May 26 '23 at 15:57
  • @FreakyAli that's not a bad idea either. I don't think I've tried that yet. – Zachary Walsh May 26 '23 at 17:46
  • @FreakyAli so I went and tried that. It 100% is firing the event inside the child object and I force fire it on the main control. But for some reason, it just does not update the UI. I even setup the event handler to set the value of SessionUser to null before setting the new value and it's not working. Any ideas? – Zachary Walsh May 26 '23 at 18:22
  • That sounds like it should have worked. Add a new section to your question, showing the exact code changes you made. (I think I know what you tried, but seeing the exact code makes sure we are all on the same page.) – ToolmakerSteve May 26 '23 at 20:05
  • As a test, what happens if you set SessionUser to a new instance? Does that correctly update UI? – ToolmakerSteve May 26 '23 at 20:12
  • I'll throw another section up later. But no it doesn't update the UI somehow. It's really bizarre – Zachary Walsh May 27 '23 at 21:08
  • For the SessionUser changed, you can use the `OnPropertyChange(nameof(SessionUser));`to notify UI update. And for the property of the SessionUser changed, you can make it implement the INotifyPopertyChanged interface or the `CommunityToolkit.Mvvm.ComponentModel.ObservableObject` to notify the UI update when the SessionUser's inner property changed. – Liyun Zhang - MSFT May 29 '23 at 08:27
  • @LiyunZhang-MSFT you're the second person to suggest this now, so I may give it a shot. If this doesn't work, do you have any other suggestions? – Zachary Walsh May 30 '23 at 12:38
  • This is the common data binding in the maui. It should work. – Liyun Zhang - MSFT May 31 '23 at 09:40

1 Answers1

0

You can try using an ObservableProperty from the CommunityToolkit.Mvvm.ComponentModel:

Change the:

private UserAccount _sessionUser;
public UserAccount SessionUser
{
    get => _sessionUser;
    private set => SetField(ref _sessionUser, value);
}

For:

using CommunityToolkit.Mvvm.ComponentModel;

[ObservableProperty] private UserAccount sessionUser;

This will automatically create a public property SessionUser.

Check the documentation for more details: ObservableProperty attribute

  • Unfortunately, this did not help since my model objects aren't at a point where I can go back and easily refactor to include this. But thank you – Zachary Walsh May 26 '23 at 18:28