Let's create a stock tracking app in .NET MAUI - part 4

Categories: .NET C#, .NET .NET MAUI
The stock tracking app is almost done. This part is about finetuning and optimizations. In this part we will implement a loading indicator and display the total portfolio value and total target % on the main page. To finish things off will revisit the StockPositions property and use a better alternative for the ObservableCollection.

Adding a loading indicator

Loading stock data from a file and and an API can take a while, and showing an empty list of stocks while the app is loading is not really user friendly. We need to inform the user somehow about the fact that the app is loading and the users needs to wait.
The ActivityIndicator does exactly that. It's just a spinning icon that is either running (visible) or not.

Alternatively, we could try to use a ProgressBar. This is probably more interesting from a UX point of view, but it's hard to show a correct progress value because stocks are not really loaded one by one. The loading logic more or less operates on the whole list as a whole.

Whether the ActivityIndicator should be running or not is determined by a boolean property IsLoading in the view model. The indicator will replace the CollectionView during the loading phase so it should use the same grid row (2).

XAML
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="IFi.Presentation.Maui.MainPage"
             xmlns:views="clr-namespace:IFi.Presentation.Maui">
    <Grid RowDefinitions="auto, auto, *">
        <!--other code omitted-->
        <ActivityIndicator Grid.Row="2" IsRunning="{Binding IsLoading}"  />
        <CollectionView Grid.Row="2" ItemsSource="{Binding StockPositions}">
            <!--other code omitted-->
        </CollectionView>
    </Grid>
</ContentPage>

This property can be set to true before initialization and it can be set to false when the initialization is done.

C#
public async Task InitializeAsync()
{
    IsLoading = true;
    foreach (var stockPosition in (await _fileService.GetStockPositionsAsync()).OrderBy(x => x.Stock.Symbol))
    {
        StockPositions.Add(stockPosition);
    }
    IsLoading = false;
}

Displaying the total portfolio value

We currently track all stocks individually, but wouldn't it be nice to see the total portfolio value? Luckily, the CollectionView has a Footer and Header integrated into it.
We will also show the total target % allocated to make it easier to work with.

XAML
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="IFiDemo.Presentation.Maui.MainPage"
             xmlns:views="clr-namespace:IFiDemo.Presentation.Maui">
    <Grid RowDefinitions="auto, auto, *">
        <!--other code omitted-->
        <CollectionView Grid.Row="2" ItemsSource="{Binding StockPositions}">
            <CollectionView.Footer>
                <StackLayout BackgroundColor="LightGray">
                    <Label Text="{Binding TotalValue, StringFormat='Total value: {0}'}" />
                    <Label Text="{Binding TotalTargetHoldingPct, StringFormat='Total target holding: {0:P2}'}" />
                </StackLayout>
            </CollectionView.Footer>
            <!--other code omitted-->
        </CollectionView>
    </Grid>
</ContentPage>

Note that this change requires changes in different places because, any time a stock is added or deleted, or the position, target % or value of a stock position changes, this has to be reflected in the footer.
Starting with the main view model, we implement the two properties. Then we attach event handlers to StockPositions (an INotifyPropertyChanged) that will be called any time a stock is added or deleted. We attach a similar event handler to the individual StockPositions that will be called any time the stock position is modified.

C#
namespace IFiDemo.Presentation.VM.Maui.ViewModels
{
    public partial class MainVM : ObservableObject
    {
        public decimal TotalValue => StockPositions?.Sum(x => x.Value) ?? 0m;
        public float TotalTargetHoldingPct => StockPositions?.Sum(x => x.TargetHoldingPct) ?? 0f;
        public MainVM(Func<Page, Task> navigate)
        {
            //other code omitted
            _fileService = new(StockPositions, StockPosition_PropertyChanged);
            ((INotifyPropertyChanged)StockPositions).PropertyChanged += StockPositions_PropertyChanged;
        }

        private void StockPositions_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            if (e.PropertyName == nameof(StockPositions.Count))
            {
                OnPropertyChanged(nameof(TotalValue));
                OnPropertyChanged(nameof(TotalTargetHoldingPct));
            }
        }

        private void StockPosition_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            if (e.PropertyName == nameof(StockPosition.Value))
            {
                OnPropertyChanged(nameof(TotalValue));
            }
            if (e.PropertyName == nameof(StockPosition.TargetHoldingPct))
            {
                OnPropertyChanged(nameof(TotalTargetHoldingPct));
            }
        }

        public async Task InitializeAsync()
        {
            IsLoading = true;
            foreach (var stockPosition in (await _fileService.GetStockPositionsAsync()).OrderBy(x => x.Stock.Symbol))
            {
                StockPositions.Add(stockPosition);
            }
            IsLoading = false;
            foreach (var stockPosition in StockPositions)
            {
                stockPosition.PropertyChanged += StockPosition_PropertyChanged;
            }
        }
        //other code omitted
    }
}

Stock positions are always instantiated in the StockMarketDataFileService so we have to attach the same event handler here. As always you can find the full code in my GitHub repository.

C#
private readonly ObservableCollection<StockPosition> _stockPositions;
private readonly PropertyChangedEventHandler _stockPositionPropertyChanged;

public StockMarketDataFileService(ObservableCollection<StockPosition> stockPositions, PropertyChangedEventHandler stockPositionPropertyChanged)
{
    _stockPositionPropertyChanged = stockPositionPropertyChanged;
    _stockPositions = stockPositions;
}

//attach in AddStockPositionAsync
//detach in DeleteStockPositionAsync

The portfolio value can now be found on the bottom of the app.

Improving the ObservableCollection

You may have found that, when sorting, or during app initialization, the app is very slow and stock positions are being added one at a time. This is because whenever this collection gets modified events are being fired and the UI needs to be updated. There is no need to inform listeners about the fact that the StockPositions property is being modified, as long as this is still ongoing.
We can implement a custom collection type, inherited from ObservableCollection<T>, that doesn't fire these events when it's not necessary - a "silent" ObservableCollection if you will.
This is actually a common problem in MVVM applications so this new class can be used in many situations.
I found the implementation below by looking into the implementation of ObservableCollection and making the necessary changes accordingly (and getting inspiration from StackOverflow 😉).

C#
namespace IFiDemo.Utilities.Collections.ObjectModel
{
    public class SilentObservableCollection<T> : ObservableCollection<T>
    {
        public void AddRange(IEnumerable<T> items)
        {
            CheckReentrancy();
            int startIndex = Count;
            foreach (var item in items)
                Items.Add(item);
            OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, new List<T>(items), startIndex));
            OnCountPropertyChanged();
            OnIndexerPropertyChanged();
        }
        public void ResetRange(IEnumerable<T> items)
        {
            CheckReentrancy();
            if (Items == items || items is not IList<T>)
            {
                items = items.ToList(); //create a shallow copy to prevent clearing the destination collection
            }
            Items.Clear();
            foreach (var item in items)
                Items.Add(item);
            OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, new List<T>(items), 0));
            OnCountPropertyChanged();
            OnIndexerPropertyChanged();
            OnCollectionReset();
        }

        private void OnCountPropertyChanged() => OnPropertyChanged(EventArgsCache.CountPropertyChanged);
        private void OnIndexerPropertyChanged() => OnPropertyChanged(EventArgsCache.IndexerPropertyChanged);
        private void OnCollectionReset() => OnCollectionChanged(EventArgsCache.ResetCollectionChanged);
        internal static class EventArgsCache
        {
            internal static readonly PropertyChangedEventArgs CountPropertyChanged = new PropertyChangedEventArgs("Count");
            internal static readonly PropertyChangedEventArgs IndexerPropertyChanged = new PropertyChangedEventArgs("Item[]");
            internal static readonly NotifyCollectionChangedEventArgs ResetCollectionChanged = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset);
        }
    }
}

With ResetRange we can now reset the collection to a new collection immediately without intermittent UI updates.

C#
public partial class MainVM : ObservableObject
{
    public async Task InitializeAsync()
    {
        IsLoading = true;
        StockPositions.ResetRange((await _fileService.GetStockPositionsAsync()).OrderBy(x => x.Stock.Symbol));
        IsLoading = false;
        foreach (var stockPosition in StockPositions)
        {
            stockPosition.PropertyChanged += StockPosition_PropertyChanged;
        }
    }
    
    private void Sort(string field)
    {
        //other code omitted
        if (stockPositions != null)
        {
            StockPositions.ResetRange(stockPositions);
        }
    }
    //other code omitted
}

Conclusion

I had a lot of fun creating this app. The goal of this series was twofold.

First, I wanted to show you that is certainly doable to create a stock track app yourself. If you find that something doesn't exist and you believe that there's a demand for it, either from yourself, of from other people, think about what it would require to get it done. Do your research and get started. You might be surprised about what you can achieve in little time

Secondly, I wanted to learn about MAUI.NET and share my journey. I find that it is - despite all the criticism - a very nice framework to work with. It might not be the best framework for Windows desktop applications, but if there's any potential that your app will go cross-platform, this is a very viable solution in the .NET ecosystem. There are many other good cross-platform frameworks for .NET (Avalonia, Electron, UNO...), but I can't speak from experience about those.

In my GitHub repository I have already made the first steps to publish this to mobile devices, however there's a lot of testing to do. I now want to focus on other things, but perhaps this journey will be continued some day.