Site icon Blog ARC Optimizer

Mises à jour des données en temps réel avec l’interface utilisateur Telerik pour .NET MAUI Grid


Apprenez à effectuer des mises à jour en temps réel dans vos applications .NET MAUI avec SignalR, courantes dans les scénarios d’actions ou de crypto-monnaie.

Dans cet article, vous apprendrez à effectuer des mises à jour en temps réel dans vos applications basées sur .NET MAUI grâce à la puissance de SignalRcourant dans les scénarios d’actions ou de crypto-monnaie. Vous verrez également pourquoi le Progress Telerik Contrôle DataGrid .NET MAUI est idéal dans ces scénarios, car il permet des fonctionnalités telles que le rendu de colonnes personnalisées grâce à l’utilisation de SkiaSharp. Commençons !

Création d’un service de mise à jour des stocks

Nous commencerons par un simple projet d’API qui simule la récupération d’actions en bourse, dans le but de vous montrer comment créer un service qui expose des informations en temps réel à l’aide de SignalR. Pour cette démonstration, j’ai créé un projet en utilisant le API Web ASP.NET Core modèle avec les classes suivantes :

Stock.cs

public class Stock
{    
    public string Symbol { get; set; } = string.Empty;
    public string CompanyName { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public decimal Change { get; set; }
    public decimal ChangePercent { get; set; }
    public decimal OpenPrice { get; set; }
    public decimal HighPrice { get; set; }
    public decimal LowPrice { get; set; }
    public decimal PreviousClose { get; set; }
    public long Volume { get; set; }
    public long MarketCap { get; set; }
    public DateTime LastUpdated { get; set; }
    public bool IsMarketOpen { get; set; }
    public string Sector { get; set; } = string.Empty;
}

StockService.cs

    public interface IStockService
    {
        List<Stock> GetAllStocks();
        Stock? GetStock(string symbol);
        event Action<Stock>? StockUpdated;
    }

    public class StockService : IStockService, IDisposable
    {
        private readonly Dictionary<string, Stock> _stocks;
        private readonly Timer _updateTimer;
        private readonly Random _random;
        private readonly int _updateIntervalMs;

        public event Action<Stock>? StockUpdated;

        public StockService(IConfiguration configuration)
        {
            _stocks = new Dictionary<string, Stock>();
            _random = new Random();
            
            _updateIntervalMs = configuration.GetValue<int>("StockSimulation:UpdateIntervalMs", 2000);

            InitializeStocks();

            _updateTimer = new Timer(UpdateStockPrices, null,
                TimeSpan.FromMilliseconds(_updateIntervalMs),
                TimeSpan.FromMilliseconds(_updateIntervalMs));
        }

        private void InitializeStocks()
        {
            var stockData = new[]
            {
            new { Symbol = "AAPL", Company = "Apple Inc.", Sector = "Technology", Price = 175.50m, MarketCap = 2800000000000L },
            new { Symbol = "GOOGL", Company = "Alphabet Inc.", Sector = "Technology", Price = 138.25m, MarketCap = 1750000000000L },
            new { Symbol = "MSFT", Company = "Microsoft Corporation", Sector = "Technology", Price = 378.90m, MarketCap = 2850000000000L },
            new { Symbol = "AMZN", Company = "Amazon.com Inc.", Sector = "Consumer Discretionary", Price = 143.75m, MarketCap = 1500000000000L },
            new { Symbol = "TSLA", Company = "Tesla Inc.", Sector = "Consumer Discretionary", Price = 248.50m, MarketCap = 790000000000L },
            new { Symbol = "NVDA", Company = "NVIDIA Corporation", Sector = "Technology", Price = 455.30m, MarketCap = 1120000000000L },
            new { Symbol = "META", Company = "Meta Platforms Inc.", Sector = "Technology", Price = 298.80m, MarketCap = 760000000000L },
            new { Symbol = "JPM", Company = "JPMorgan Chase & Co.", Sector = "Financial Services", Price = 158.45m, MarketCap = 460000000000L },
            new { Symbol = "V", Company = "Visa Inc.", Sector = "Financial Services", Price = 265.90m, MarketCap = 520000000000L },
            new { Symbol = "JNJ", Company = "Johnson & Johnson", Sector = "Healthcare", Price = 162.30m, MarketCap = 425000000000L }
        };

            foreach (var data in stockData)
            {
                var stock = new Stock
                {
                    Symbol = data.Symbol,
                    CompanyName = data.Company,
                    Sector = data.Sector,
                    Price = data.Price,
                    PreviousClose = data.Price + (decimal)(_random.NextDouble() * 10 - 5),
                    OpenPrice = data.Price + (decimal)(_random.NextDouble() * 6 - 3),
                    MarketCap = data.MarketCap,
                    Volume = _random.NextInt64(1000000, 50000000),
                    IsMarketOpen = IsMarketOpen(),
                    LastUpdated = DateTime.UtcNow
                };
                
                stock.HighPrice = stock.OpenPrice + Math.Abs((decimal)(_random.NextDouble() * 8));
                stock.LowPrice = stock.OpenPrice - Math.Abs((decimal)(_random.NextDouble() * 8));
                stock.Change = stock.Price - stock.PreviousClose;
                stock.ChangePercent = stock.PreviousClose != 0 ? (stock.Change / stock.PreviousClose) * 100 : 0;

                _stocks[data.Symbol] = stock;
            }
        }

        private void UpdateStockPrices(object? state)
        {
            if (!IsMarketOpen()) return;

            foreach (var stock in _stocks.Values)
            {                
                var changePercent = (_random.NextDouble() - 0.5) * 0.04;
                var priceChange = stock.Price * (decimal)changePercent;

                stock.Price = Math.Max(0.01m, stock.Price + priceChange);
                stock.Change = stock.Price - stock.PreviousClose;
                stock.ChangePercent = stock.PreviousClose != 0 ? (stock.Change / stock.PreviousClose) * 100 : 0;
                
                if (stock.Price > stock.HighPrice)
                    stock.HighPrice = stock.Price;
                if (stock.Price < stock.LowPrice)
                    stock.LowPrice = stock.Price;
                
                stock.Volume += _random.NextInt64(10000, 500000);
                stock.LastUpdated = DateTime.UtcNow;
                
                StockUpdated?.Invoke(stock);
            }
        }

        private static bool IsMarketOpen()
        {
            var now = DateTime.Now;
            var timeOfDay = now.TimeOfDay;

            return true;
        }

        public List<Stock> GetAllStocks()
        {
            return _stocks.Values.ToList();
        }

        public Stock? GetStock(string symbol)
        {
            _stocks.TryGetValue(symbol.ToUpper(), out var stock);
            return stock;
        }

        public void Dispose()
        {
            _updateTimer?.Dispose();
        }
    }

Dans le code ci-dessus, nous simulons un service Stock qui met à jour ses informations toutes les 2 secondes. j’ai aussi modifié Program.cs pour créer le point de terminaison qui permet d’interroger tous les stocks :

var builder = WebApplication.CreateBuilder(args);
...
builder.Services.AddSingleton<IStockService, StockService>();
var app = builder.Build();
...
var stocksApi = app.MapGroup("/api/stocks");


stocksApi.MapGet("https://www.telerik.com/", (IStockService stockService, ILogger<Program> logger) =>
{
    try
    {
        var stocks = stockService.GetAllStocks();
        return Results.Ok(stocks);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Failed to retrieve all stocks");
        return Results.Problem("Internal Server Error", statusCode: 500);
    }
})
.WithName("GetAllStocks")
.WithOpenApi();
...

app.Run();

En démarrant le service et en le testant, nous pouvons voir comment chaque exécution produit des informations différentes pour les actions :

Création d’un hub pour la transmission de données en temps réel

Une fois que nous avons vérifié que l’API fonctionne correctement, l’étape suivante consiste à créer un SignalR Hub, qui est le canal de communication utilisé pour envoyer et recevoir des messages. Pour y parvenir, nous devons définir une classe permettant aux clients de se connecter aux actions qu’ils souhaitent surveiller.

Dans notre exemple, pour simplifier les choses, nous définirons les méthodes permettant aux clients de souscrire et de se désinscrire des variations de toutes les actions comme suit :

StockHub.cs

public class StockHub : Hub
{
    private readonly IStockService _stockService;

    public StockHub(IStockService stockService)
    {
        _stockService = stockService;
    }
    
    public async Task SubscribeToAllStocks()
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, "all_stocks");
        
        var stocks = _stockService.GetAllStocks();
        await Clients.Caller.SendAsync("AllStocksUpdate", stocks);
    }
   
    public async Task UnsubscribeFromAllStocks()
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, "all_stocks");
    }
    
    public async Task GetAllStocks()
    {
        var stocks = _stockService.GetAllStocks();
        await Clients.Caller.SendAsync("AllStocksSnapshot", stocks);
    }

    public override async Task OnConnectedAsync()
    {
        await Clients.Caller.SendAsync("Connected", $"Connected to the Stocks Hub. ID: {Context.ConnectionId}");
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {            
        await base.OnDisconnectedAsync(exception);
    }
}

Vous remarquerez peut-être qu’aucune installation supplémentaire n’a été nécessaire, car SignalR fait partie du framework ASP.NET Core. Certains concepts importants du code précédent que vous devez connaître au cas où vous n’auriez pas travaillé avec SignalR sont les suivants :

  • Groupes: La classe de gestionnaire de groupe
  • Contexte.ConnectionId: Un identifiant unique temporaire pour la connexion d’un client au Hub
  • tous_stocks: Le label du groupe qui recevra les mises à jour sur les stocks
  • Clients.Caller.SendAsync: Envoie un événement appelé AllStocksUpdate uniquement au client qui a passé l’appel, avec les données de mise à jour

Après avoir créé le Hub, nous devons le mapper afin que les clients puissent s’y connecter. Cela devrait être fait dans Program.cs:

...
builder.Services.AddSignalR();
var app = builder.Build();
...
app.MapHub<StockHub>("/stockHub");
var stocksApi = app.MapGroup("/api/stocks");
...

Pour tester l’application, j’ai créé une application console que vous pouvez voir en action ci-dessous :

Comme vous pouvez le voir sur l’image ci-dessus, lorsque nous nous abonnons au Hub pour recevoir des notifications en temps réel, nous ne recevons que la première mise à jour même si de nouvelles données sont générées en coulisses toutes les deux secondes. Cela se produit parce que nous n’envoyons pas les mises à jour des nouvelles données via le Hub ; nous le faisons uniquement en interne pour l’API.

Il est possible de créer un Service d’arrière-plan responsable de l’envoi des diffusions via le Hub. Cela se produira lorsqu’une mise à jour est détectée via l’événement StockUpdated défini dans StockService:

StockUpdateBroadcastService.cs

public class StockUpdateBroadcastService : BackgroundService
{
    private readonly IHubContext<StockHub> _hubContext;
    private readonly IStockService _stockService;
    private readonly ILogger<StockUpdateBroadcastService> _logger;

    public StockUpdateBroadcastService(
        IHubContext<StockHub> hubContext,
        IStockService stockService,
        ILogger<StockUpdateBroadcastService> logger)
    {
        _hubContext = hubContext;
        _stockService = stockService;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {        
        _stockService.StockUpdated += OnStockUpdated;

        _logger.LogInformation("Stock update broadcast service started");
        
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(1000, stoppingToken);
        }
        
        _stockService.StockUpdated -= OnStockUpdated;
        _logger.LogInformation("Stock update broadcast service stopped");
    }

    private async void OnStockUpdated(Models.Stock stock)
    {
        try
        {            
            await _hubContext.Clients.Group($"stock_{stock.Symbol}")
                .SendAsync("StockUpdate", stock);

            await _hubContext.Clients.Group("all_stocks")
                .SendAsync("StockUpdate", stock);

            _logger.LogDebug($"Update for {stock.Symbol} broadcasted: ${stock.Price:F2}");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, $"Error broadcasting update for {stock.Symbol}");
        }
    }
}

Il est essentiel d’enregistrer le service d’arrière-plan dans le conteneur de dépendances dans Program.cs:

...
builder.Services.AddHostedService<StockUpdateBroadcastService>();

var app = builder.Build();

Lors de l’exécution de l’application avec ce changement, nous verrons que l’application console reçoit désormais des mises à jour toutes les 2 secondes :

Une fois que nous avons vérifié que tout fonctionne correctement, voyons comment connecter une application .NET MAUI au service qui communique des informations en temps réel.

Création d’une application .NET MAUI pour recevoir des notifications en temps réel

L’étape suivante consiste à créer l’application .NET MAUI pour recevoir les informations boursières et les afficher dans l’interface utilisateur. Le framework n’inclut pas de contrôle DataGrid pour afficher les données boursières de manière simple. Heureusement, la suite Telerik pour .NET MAUI dispose d’une implémentation assez robuste et flexible du Contrôle DataGrid .NET MAUIce qui nous permettra d’afficher les données rapidement.

Pour y parvenir, vous devez d’abord créer ou ouvrir un projet .NET MAUI, puis installer les contrôles Telerik conformément aux instructions. guide d’installation. Ensuite, installez les packages NuGet suivants :

  • CommunityToolkit.Mvvm
  • Microsoft.AspNetCore.SignalR.Client

De même, il est important de disposer d’un modèle qui représente les informations boursières ainsi qu’un historique simulé des actions et des méthodes qui simulent les initialisations, les mises à jour, etc. :

Stock.cs

public partial class Stock : ObservableObject
{
    [ObservableProperty]
    private string symbol = string.Empty;

    [ObservableProperty]
    private string companyName = string.Empty;

    [ObservableProperty]
    private decimal price;

    [ObservableProperty]
    private decimal change;

    [ObservableProperty]
    private decimal changePercent;

    [ObservableProperty]
    private decimal openPrice;

    [ObservableProperty]
    private decimal highPrice;

    [ObservableProperty]
    private decimal lowPrice;

    [ObservableProperty]
    private decimal previousClose;

    [ObservableProperty]
    private long volume;

    [ObservableProperty]
    private long marketCap;

    [ObservableProperty]
    private DateTime lastUpdated;

    [ObservableProperty]
    private bool isMarketOpen;

    [ObservableProperty]
    private string sector = string.Empty;
    
    public List<double> PriceHistory { get; private set; } = new List<double>();
    
    public string PriceText => $"${Price:F2}";

    public string ChangeText => Change >= 0 ? $"+${Change:F2}" : $"-${Math.Abs(Change):F2}";

    public string ChangePercentText => $"({(Change >= 0 ? "+" : "")}{ChangePercent:F2}%)";

    public Color ChangeColor => Change >= 0 ? Colors.Green : Colors.Red;

    public string VolumeText => Volume.ToString("N0");

    public string MarketCapText => FormatMarketCap(MarketCap);

    public string LastUpdatedText => LastUpdated.ToString("HH:mm:ss");

    private static string FormatMarketCap(long marketCap)
    {
        if (marketCap >= 1_000_000_000_000)
            return $"${marketCap / 1_000_000_000_000.0:F1}T";
        else if (marketCap >= 1_000_000_000)
            return $"${marketCap / 1_000_000_000.0:F1}B";
        else if (marketCap >= 1_000_000)
            return $"${marketCap / 1_000_000.0:F1}M";
        else
            return $"${marketCap:N0}";
    }
    
    public void UpdateFrom(Stock newStock)
    {
        Symbol = newStock.Symbol;
        CompanyName = newStock.CompanyName;
        
        AddPriceToHistory((double)newStock.Price);

        Price = newStock.Price;
        Change = newStock.Change;
        ChangePercent = newStock.ChangePercent;
        OpenPrice = newStock.OpenPrice;
        HighPrice = newStock.HighPrice;
        LowPrice = newStock.LowPrice;
        PreviousClose = newStock.PreviousClose;
        Volume = newStock.Volume;
        MarketCap = newStock.MarketCap;
        LastUpdated = newStock.LastUpdated;
        IsMarketOpen = newStock.IsMarketOpen;
        Sector = newStock.Sector;
        
        OnPropertyChanged(nameof(PriceText));
        OnPropertyChanged(nameof(ChangeText));
        OnPropertyChanged(nameof(ChangePercentText));
        OnPropertyChanged(nameof(ChangeColor));
        OnPropertyChanged(nameof(VolumeText));
        OnPropertyChanged(nameof(MarketCapText));
        OnPropertyChanged(nameof(LastUpdatedText));
    }
    
    private void AddPriceToHistory(double price)
    {
        PriceHistory.Add(price);
        
        if (PriceHistory.Count > 20)
        {
            PriceHistory.RemoveAt(0);
        }
    }
    
    public void InitializePriceHistory(double initialPrice)
    {
        if (PriceHistory.Count == 0)
        {                
            for (int i = 0; i < 10; i++)
            {
                double variation = (Random.Shared.NextDouble() - 0.5) * 0.1;
                PriceHistory.Add(initialPrice * (1 + variation));
            }
        }
    }
}

Avec le modèle prêt dans l’application .NET MAUI, nous pouvons continuer avec le service de connexion.

Création du service pour recevoir les notifications SignalR

Afin de connecter un client à un Hub SignalR, il est nécessaire de créer un HubConnection. Dans notre application .NET MAUI, nous allons créer une classe qui gérera cette connexion, ainsi que des propriétés et des événements qui permettront à l’application d’apporter les modifications nécessaires lorsqu’elle recevra une mise à jour des données.

La classe se présente comme suit :

StockSignalRService.cs

public partial class StockSignalRService : ObservableObject, IDisposable
{
    private readonly HubConnection _connection;
    private bool _isConnected = false;

    [ObservableProperty]
    private string connectionStatus = "Disconnected";

    [ObservableProperty]
    private bool isConnecting = false;

    public ObservableCollection<Stock> Stocks { get; } = new();
    public bool IsConnected => _isConnected;

    public event Action<string>? MessageReceived;
    public event Action<Stock>? StockUpdated;

    public StockSignalRService()
    {
        var hubUrl = DeviceInfo.Platform == DevicePlatform.Android
            ? "http://10.0.2.2:5031/stockHub"
            : "http://localhost:5134/stockHub";

        _connection = new HubConnectionBuilder()
            .WithUrl(hubUrl)
            .WithAutomaticReconnect()
            .Build();

        ConfigureEventHandlers();
    }

    private void ConfigureEventHandlers()
    {        
        _connection.Closed += OnDisconnected;
        _connection.Reconnected += OnReconnected;
        _connection.Reconnecting += OnReconnecting;
        
        _connection.On<Stock>("StockUpdate", OnStockUpdate);
        _connection.On<List<Stock>>("AllStocksUpdate", OnAllStocksUpdate);            
        _connection.On<List<Stock>>("AllStocksSnapshot", OnAllStocksSnapshot);
        _connection.On<string>("Connected", OnConnectedMessage);            
    }

    public async Task<bool> ConnectAsync()
    {
        if (_isConnected) return true;

        try
        {
            IsConnecting = true;
            ConnectionStatus = "Connecting...";

            await _connection.StartAsync();
            _isConnected = true;
            ConnectionStatus = "Connected";
            
            await SubscribeToAllStocksAsync();

            return true;
        }
        catch (Exception ex)
        {
            ConnectionStatus = $"Error: {ex.Message}";
            MessageReceived?.Invoke($"Connection error: {ex.Message}");
            return false;
        }
        finally
        {
            IsConnecting = false;
        }
    }

    public async Task DisconnectAsync()
    {
        if (!_isConnected) return;

        try
        {
            await _connection.StopAsync();
            _isConnected = false;
            ConnectionStatus = "Disconnected";
            
            MainThread.BeginInvokeOnMainThread(() =>
            {
                Stocks.Clear();
            });
        }
        catch (Exception ex)
        {
            MessageReceived?.Invoke($"Error while disconnecting: {ex.Message}");
        }
    }

    public async Task<bool> SubscribeToAllStocksAsync()
    {
        if (!_isConnected) return false;

        try
        {
            await _connection.InvokeAsync("SubscribeToAllStocks");
            MessageReceived?.Invoke("Subscribed to all stocks");
            return true;
        }
        catch (Exception ex)
        {
            MessageReceived?.Invoke($"Subscription error: {ex.Message}");
            return false;
        }
    }

    public async Task<bool> GetAllStocksAsync()
    {
        if (!_isConnected) return false;

        try
        {
            await _connection.InvokeAsync("GetAllStocks");
            return true;
        }
        catch (Exception ex)
        {
            MessageReceived?.Invoke($"Error retrieving stocks: {ex.Message}");
            return false;
        }
    }

    #region Event Handlers

    private Task OnDisconnected(Exception? exception)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            _isConnected = false;
            ConnectionStatus = exception != null ? $"Disconnected: {exception.Message}" : "Disconnected";
            Stocks.Clear();
        });
        return Task.CompletedTask;
    }

    private Task OnReconnected(string? connectionId)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            _isConnected = true;
            ConnectionStatus = "Reconnected";
        });

        _ = Task.Run(async () => await SubscribeToAllStocksAsync());

        return Task.CompletedTask;
    }

    private Task OnReconnecting(Exception? exception)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            _isConnected = false;
            ConnectionStatus = "Reconnecting...";
        });
        return Task.CompletedTask;
    }

    private void OnStockUpdate(Stock stock)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            var existingStock = Stocks.FirstOrDefault(s => s.Symbol == stock.Symbol);
            if (existingStock != null)
            {
                existingStock.UpdateFrom(stock);
            }
            else
            {
                Stocks.Add(stock);
            }

            StockUpdated?.Invoke(stock);
        });
    }

    private void OnAllStocksUpdate(List<Stock> stocks)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            foreach (var stock in stocks)
            {
                var existingStock = Stocks.FirstOrDefault(s => s.Symbol == stock.Symbol);
                if (existingStock != null)
                {
                    existingStock.UpdateFrom(stock);
                }
                else
                {
                    Stocks.Add(stock);
                }
            }
        });
    }

    private void OnAllStocksSnapshot(List<Stock> stocks)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            Stocks.Clear();
            foreach (var stock in stocks)
            {                
                stock.InitializePriceHistory((double)stock.Price);
                Stocks.Add(stock);
            }
        });
        MessageReceived?.Invoke($"Snapshot received for {stocks.Count} stocks");
    }

    private void OnConnectedMessage(string message)
    {
        MessageReceived?.Invoke(message);
    }

    #endregion

    public void Dispose()
    {
        _connection?.DisposeAsync();
    }
}

Dans le code ci-dessus, nous devons souligner quelques points :

  • Si vous suivez l’exercice, vous devez changer de port en fonction de votre service.
  • Événements MessageReceived et StockUpdated sont créés pour qu’une classe externe puisse savoir quand un message a été généré et quand il y a une mise à jour des stocks.
  • HubConnectionBuilder est utilisé pour créer HubConnection.
  • Les événements auxquels le client s’abonnera depuis le Hub sont enregistrés via _connection.On. Vous remarquerez que les noms sont les mêmes que ceux définis dans StockHub.cs du projet Web API.
  • Lorsqu’un client se connecte via ConnectAsyncla souscription est initiée via _connection.StartAsync()et il s’abonne automatiquement pour écouter toutes les actions via SubscribeToAllStocksAsync().

Avec le service d’abonnement aux messages SignalR, l’étape suivante consiste à créer le modèle d’affichage de la page.

Création du ViewModel de l’application

La création du modèle de vue pour le projet est très similaire à n’importe quel modèle de vue .NET MAUI utilisant le MVVM Toolkit, la différence étant l’utilisation du service. StockSignalRService:

MainViewModel.cs

public partial class MainViewModel : ObservableObject
{
    [ObservableProperty]
    private bool isLoading = false;
    [ObservableProperty]
    private bool isConnected = false;

    private readonly StockSignalRService _stockService;
    
    public ObservableCollection<Stock> Stocks => _stockService.Stocks;

    public MainViewModel(StockSignalRService stockService)
    {
        _stockService = stockService;            
        
        _ = Task.Run(async () => await InitializeConnectionAsync());
    }

    private async Task InitializeConnectionAsync()
    {
        try
        {                
            await _stockService.ConnectAsync();
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Connection Error: {ex.Message}");
        }
    }                           
}

Dans le code ci-dessus, soulignons quelques éléments :

  • Le service StockSignalRService est utilisé pour se connecter au SignalR Hub.
  • Une collection de types Stock est défini, qui est lié au Stocks propriété du service, ce qui signifie que lorsqu’il y a une notification de modification du service, la collection aura automatiquement ces nouvelles valeurs, mettant automatiquement à jour l’interface utilisateur.
  • Une fois la page affichée, la méthode ConnectAsync du service est invoqué, qui, pour rappel, s’abonne aux mises à jour de tous les stocks.

Création de la page avec le DataGrid dans l’application

La dernière partie du projet consiste à créer la ContentPage qui affichera des informations mises à jour en temps réel.

Utiliser un composant comme CollectionView pour afficher des données, en plus d’implémenter des fonctionnalités telles que le tri, le regroupement, le filtrage, etc., peut être un véritable casse-tête. Heureusement, dans la suite de contrôle Telerik, nous avons le contrôle DataGrid, qui inclut les fonctionnalités susmentionnées et bien d’autres de manière native.

Pour notre exemple, nous utiliserons un RadDataGrid cela occupera toute la page, bien que vous puissiez modifier cette page pour afficher des informations telles que l’heure de la dernière mise à jour, le nombre de mises à jour, le choix des actions à afficher, etc.

La page dans sa première version ressemblera à ceci :

<Grid>
    
    <telerik:RadDataGrid
        x:Name="StocksDataGrid"
        AutoGenerateColumns="True"
        Background="White"
        GridLinesColor="LightGray"
        ItemsSource="{Binding Stocks}"
        RowHeight="50">
    </telerik:RadDataGrid>

</Grid>

Dans le code derrière la page, nous devons modifier le code pour recevoir le modèle de vue et le lier au BindingContext:

public partial class MainPage : ContentPage
{
    private readonly MainViewModel _viewModel;

    public MainPage(MainViewModel viewModel)
    {
        InitializeComponent();
        _viewModel = viewModel;
        BindingContext = _viewModel;
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();            
    }
}

Enfin, vous devez modifier MauiProgram.cs pour permettre au conteneur de dépendances de résoudre les injections de dépendances :

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
       ...

        builder.Services.AddScoped<StockSignalRService>();
        builder.Services.AddScoped<MainViewModel>();            
        builder.Services.AddScoped<MainPage>();            

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

Une fois que nous avons fait ce qui précède, nous pouvons voir la page en action :

Bien que la grille affiche curieusement certaines données, nous avons vérifié que la mise à jour se produit correctement toutes les 2 secondes, comme vous pouvez le voir dans le Texte du prix, Changer le texte et ChangePercentText colonnes.

Améliorer le DataGrid pour un meilleur affichage des informations

L’une des choses que j’aime dans le contrôle Telerik DataGrid, ce sont ses options de personnalisation. Cela se traduit par la possibilité de décider quelles colonnes inclure dans le DataGrid et de les formater en fonction de nos besoins. Pour ce faire, nous devons suivre ces étapes :

  1. Placer la propriété AutoGenerateColumns dans False.
  2. Ajouter une rubrique <telerik:RadDataGrid.Columns> à l’intérieur de la définition RadDataGrid.
  3. Créez les colonnes en fonction du type de données que vous souhaitez afficher (dans notre cas, elles seront du type DataGridTextColumn).
  4. Configurez les propriétés de chaque colonne en fonction des opérations que l’on souhaite autoriser (tri, filtrage, etc.).

Le code de notre exemple ressemblera à ceci :

<ContentPage.Resources>
    <ResourceDictionary>
        <Style x:Key="HeaderStyle" TargetType="telerik:DataGridColumnHeaderAppearance">
            <Setter Property="BackgroundColor" Value="DarkBlue" />
            <Setter Property="TextColor" Value="White" />
        </Style>
    </ResourceDictionary>
</ContentPage.Resources>

<Grid>
    <telerik:RadDataGrid
        x:Name="StocksDataGrid"
        AutoGenerateColumns="False"
        Background="White"
        GridLinesColor="LightGray"
        ItemsSource="{Binding Stocks}"
        RowHeight="50">

        <telerik:RadDataGrid.Columns>
            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserGroup="True"
                CanUserSort="True"
                HeaderText="Symbol"
                PropertyName="Symbol">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
            </telerik:DataGridTextColumn>

            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserGroup="True"
                CanUserSort="True"
                HeaderText="Company Name"
                PropertyName="CompanyName">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
            </telerik:DataGridTextColumn>

            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserSort="True"
                HeaderText="Price"
                PropertyName="Price">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
                <telerik:DataGridTextColumn.CellContentTemplate>
                    <DataTemplate>
                        <Label
                            HorizontalOptions="End"
                            Text="{Binding PriceText}"
                            VerticalOptions="Center" />
                    </DataTemplate>
                </telerik:DataGridTextColumn.CellContentTemplate>
            </telerik:DataGridTextColumn>

            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserSort="True"
                HeaderText="Change"
                PropertyName="Change">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
                <telerik:DataGridTextColumn.CellContentTemplate>
                    <DataTemplate>
                        <Label
                            HorizontalOptions="End"
                            Text="{Binding ChangeText}"
                            TextColor="{Binding ChangeColor}"
                            VerticalOptions="Center" />
                    </DataTemplate>
                </telerik:DataGridTextColumn.CellContentTemplate>
            </telerik:DataGridTextColumn>

            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserSort="True"
                HeaderText="% Change"
                PropertyName="ChangePercent">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
                <telerik:DataGridTextColumn.CellContentTemplate>
                    <DataTemplate>
                        <Label
                            HorizontalOptions="End"
                            Text="{Binding ChangePercentText}"
                            TextColor="{Binding ChangeColor}"
                            VerticalOptions="Center" />
                    </DataTemplate>
                </telerik:DataGridTextColumn.CellContentTemplate>
            </telerik:DataGridTextColumn>

            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserSort="True"
                HeaderText="Volume"
                PropertyName="Volume">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
                <telerik:DataGridTextColumn.CellContentTemplate>
                    <DataTemplate>
                        <Label
                            HorizontalOptions="End"
                            Text="{Binding VolumeText}"
                            VerticalOptions="Center" />
                    </DataTemplate>
                </telerik:DataGridTextColumn.CellContentTemplate>
            </telerik:DataGridTextColumn>

            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserSort="True"
                HeaderText="Market Cap"
                PropertyName="MarketCap">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
                <telerik:DataGridTextColumn.CellContentTemplate>
                    <DataTemplate>
                        <Label
                            HorizontalOptions="End"
                            Text="{Binding MarketCapText}"
                            VerticalOptions="Center" />
                    </DataTemplate>
                </telerik:DataGridTextColumn.CellContentTemplate>
            </telerik:DataGridTextColumn>

            <telerik:DataGridTextColumn
                CanUserFilter="True"
                CanUserGroup="True"
                CanUserSort="True"
                HeaderText="Sector"
                PropertyName="Sector">
                <telerik:DataGridTextColumn.HeaderStyle>
                    <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
                </telerik:DataGridTextColumn.HeaderStyle>
            </telerik:DataGridTextColumn>
        </telerik:RadDataGrid.Columns>

    </telerik:RadDataGrid>

</Grid>

Une fois les modifications précédentes effectuées, vous pouvez constater que le DataGrid est bien meilleur :

Création d’une colonne personnalisée pour afficher un format différent

Il est très probable qu’à un moment donné, vous souhaitiez afficher un graphique personnalisé dans votre DataGrid. Par exemple, dans les applications boursières, il est courant d’afficher les tendances des prix au moyen de graphiques. Le contrôle Telerik DataGrid est rendu à l’aide de la bibliothèque SkiaSharp, qui nous permet de créer des graphiques aussi complexes que nous le souhaitons. Il y en a plusieurs tutoriels sur l’utilisation de SkiaSharp dans .NET MAUI disponible en ligne.

Pour le cas du DataGrid, il faut utiliser comme base la classe DataGridCellRendererqui contient une méthode appelée RenderContainer que nous pouvons utiliser pour restituer le contenu personnalisé de l’élément actuel. Dans notre exemple, j’ai créé une classe appelée SparklineRenderer ça ressemble à ça :

public class SparklineRenderer : DataGridCellRenderer
{
    protected override void RenderContainer(DataGridCellRendererRenderContext renderContext)
    {
        if (renderContext.Item is Stock stock && 
            renderContext is DataGridSkiaSharpCellRendererRenderContext skRenderContext)
        {
            DrawSparkline(stock, skRenderContext, skRenderContext.Bounds);
        }
    }

    private void DrawSparkline(Stock stock, DataGridSkiaSharpCellRendererRenderContext context, Microsoft.Maui.Graphics.Rect bounds)
    {        
        var priceHistory = stock.PriceHistory;
        if (priceHistory == null || priceHistory.Count < 2)
            return;

        double padding = 4;
        double chartWidth = bounds.Width - (2 * padding);
        double chartHeight = bounds.Height - (2 * padding);
        double chartX = bounds.X + padding;
        double chartY = bounds.Y + padding;

        double displayScale = context.DisplayScale;
        
        double minPrice = priceHistory.Min();
        double maxPrice = priceHistory.Max();
        double priceRange = maxPrice - minPrice;
                
        if (priceRange == 0)
            priceRange = 1;
        
        var points = new List<SKPoint>();
        for (int i = 0; i < priceHistory.Count; i++)
        {
            double x = chartX + (i * chartWidth / (priceHistory.Count - 1));
            double normalizedValue = (priceHistory[i] - minPrice) / priceRange;
            double y = chartY + chartHeight - (normalizedValue * chartHeight);
            
            points.Add(new SKPoint((float)(x * displayScale), (float)(y * displayScale)));
        }
        
        double totalChange = priceHistory.Last() - priceHistory.First();
        SKColor lineColor = totalChange >= 0 ? 
            new SKColor(34, 197, 94) :
            new SKColor(239, 68, 68);
        
        using (var paint = new SKPaint())
        {
            paint.Color = lineColor;
            paint.StrokeWidth = 1.5f * (float)displayScale;
            paint.Style = SKPaintStyle.Stroke;
            paint.IsAntialias = true;

            using (var path = new SKPath())
            {
                if (points.Count > 0)
                {
                    path.MoveTo(points[0]);
                    for (int i = 1; i < points.Count; i++)
                    {
                        path.LineTo(points[i]);
                    }
                    context.Canvas.DrawPath(path, paint);
                }
            }
        }
        
        using (var paint = new SKPaint())
        {
            paint.Color = lineColor;
            paint.Style = SKPaintStyle.Fill;
            paint.IsAntialias = true;

            float dotRadius = 1f * (float)displayScale;
            foreach (var point in points)
            {
                context.Canvas.DrawCircle(point.X, point.Y, dotRadius, paint);
            }
        }
        
        if (points.Count > 0)
        {
            using (var paint = new SKPaint())
            {
                paint.Color = lineColor;
                paint.Style = SKPaintStyle.Fill;
                paint.IsAntialias = true;

                var lastPoint = points.Last();
                float highlightRadius = 2f * (float)displayScale;
                context.Canvas.DrawCircle(lastPoint.X, lastPoint.Y, highlightRadius, paint);
            }
        }
    }
}

Examinons quelques points importants du code précédent :

  1. Il hérite de DataGridCellRendererune classe essentielle pour afficher le contenu dans le DataGrid.
  2. La méthode RenderContainer est utilisé pour dessiner le contenu.
  3. L’historique des stocks est obtenu, qui est une liste de valeurs précédentes simulées.
  4. Une zone pour dessiner le graphique est définie.
  5. Dans la première boucle forl’histoire est transformée en normalisé X et Y coordonnées en fonction de la zone cartographique.
  6. À travers totalChange et lineColoril est déterminé si la couleur doit être verte ou rouge.
  7. UN SKPath est créé avec les points calculés et le graphique est dessiné.
  8. De petits cercles sont ajoutés en utilisant DrawCircle pour mettre en évidence chaque point individuel.
  9. Un cercle plus grand est dessiné pour représenter la valeur la plus récente.

Une fois que nous aurons le nouveau moteur de rendu, nous l’utiliserons dans le DataGrid comme suit :

<telerik:DataGridTextColumn
    CanUserFilter="False"
    CanUserSort="False"
    CellRenderer="{StaticResource SparklineRenderer}"
    HeaderText="Trend"
    PropertyName="Symbol">
    <telerik:DataGridTextColumn.HeaderStyle>
        <Style BasedOn="{StaticResource HeaderStyle}" TargetType="telerik:DataGridColumnHeaderAppearance" />
    </telerik:DataGridTextColumn.HeaderStyle>
</telerik:DataGridTextColumn>

Avec la nouvelle cellule ajoutée au DataGrid, nous verrons que le graphique personnalisé apparaît dans l’interface utilisateur :

Sans aucun doute, la possibilité de personnaliser le contenu des cellules ouvre une nouvelle opportunité pour présenter des informations rapides et précises aux utilisateurs de l’application.

Conclusion

Tout au long de cet article, vous avez appris à créer des applications basées sur SignalR pour obtenir une communication en temps réel, ce qui est crucial dans les applications boursières, de crypto-monnaie ou similaires.

Vous avez également vu comment le contrôle Telerik UI pour .NET MAUI DataGrid est une excellente option pour afficher ce contenu, non seulement pour ses propriétés permettant de travailler avec les données, mais également pour sa flexibilité de personnalisation. Il est temps d’offrir à vos clients des expériences uniques basées sur les données.

Essayez l’interface utilisateur pour .NET MAUI




Source link
Quitter la version mobile