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, theUserAccountView
just takes the type ofUserAccount
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>
whereTObjectType
is the type of object I'm holding a collection of, andTParentType
is the type of object I'm getting this collection from.
- To display a single object, I create a new instance of
- 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 aUserAccount
instance. - This instance is named
SessionUser
. TheUserAccount
property is setup to fire aPropertyChanged
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 theSessionUser
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 aPropertyChanged
event when something in theSessionUser
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 theSessionUser
property instead of aBindableProperty
. 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 thatPropertyChanged
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 toBindableProperty
.- 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.
- This did not fix the issue. Child properties were still not notifying the owning
- Attach a new callback onto the
SessionUser
event forPropertyChanged
.- 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