Partager via


Tutoriel : Interface utilisateur distante avancée

Dans ce tutoriel, vous allez découvrir les concepts avancés de l’interface utilisateur distante en modifiant de façon incrémentielle une fenêtre d’outil qui affiche une liste de couleurs aléatoires :

Capture d’écran montrant la fenêtre de l’outil de couleurs aléatoires.

Vous en apprendrez davantage sur :

  • Comment plusieurs exécutions de commandes asynchrones peuvent s’exécuter en parallèle et comment désactiver les éléments d’interface utilisateur lorsqu’une commande est en cours d’exécution.
  • Comment lier plusieurs boutons à la même commande asynchrone.
  • Comment les types de référence sont gérés dans le contexte de données de l’interface utilisateur distante et son proxy.
  • Comment utiliser une commande asynchrone en tant que gestionnaire d’événements.
  • Comment désactiver un seul bouton lorsque le rappel de sa commande asynchrone s’exécute, si plusieurs boutons sont liés à la même commande.
  • Comment utiliser des dictionnaires de ressources XAML à partir d’un contrôle d’interface utilisateur distant.
  • Comment utiliser des types WPF, tels que des pinceaux complexes, dans le contexte de données de l’interface utilisateur distante.
  • Comment l’interface utilisateur distante gère le threading.

Ce tutoriel est basé sur l’article d’introduction de l’interface utilisateur distante et s’attend à ce que vous disposiez d’une extension VisualStudio.Extensibility opérationnelle, notamment :

  1. un .cs fichier pour la commande qui ouvre la fenêtre outil,
  2. un MyToolWindow.cs fichier pour la ToolWindow classe,
  3. un MyToolWindowContent.cs fichier pour la RemoteUserControl classe,
  4. un fichier de ressources incorporé MyToolWindowContent.xaml pour la définition XAML de RemoteUserControl,
  5. un fichier MyToolWindowData.cs pour le contexte de données de RemoteUserControl.

Pour démarrer, mettez à jour MyToolWindowContent.xaml pour afficher un affichage de liste et un bouton » :

<DataTemplate xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vs="http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml"
              xmlns:styles="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0"
              xmlns:colors="clr-namespace:Microsoft.VisualStudio.PlatformUI;assembly=Microsoft.VisualStudio.Shell.15.0">
    <Grid x:Name="RootGrid">
        <Grid.Resources>
            <Style TargetType="ListView" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ThemedDialogListViewStyleKey}}" />
            <Style TargetType="Button" BasedOn="{StaticResource {x:Static styles:VsResourceKeys.ButtonStyleKey}}" />
            <Style TargetType="TextBlock">
                <Setter Property="Foreground" Value="{DynamicResource {x:Static styles:VsBrushes.WindowTextKey}}" />
            </Style>
        </Grid.Resources>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <ListView ItemsSource="{Binding Colors}" HorizontalContentAlignment="Stretch">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*" />
                            <ColumnDefinition Width="Auto" />
                            <ColumnDefinition Width="Auto" />
                        </Grid.ColumnDefinitions>
                        <TextBlock Text="{Binding ColorText}" />
                        <Rectangle Fill="{Binding Color}" Width="50px" Grid.Column="1" />
                        <Button Content="Remove" Grid.Column="2" />
                    </Grid>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <Button Content="Add color" Command="{Binding AddColorCommand}" Grid.Row="1" />
    </Grid>
</DataTemplate>

Ensuite, mettez à jour la classe MyToolWindowData.csde contexte de données :

using Microsoft.VisualStudio.Extensibility.UI;
using System.Collections.ObjectModel;
using System.Runtime.Serialization;
using System.Text;
using System.Windows.Media;

namespace MyToolWindowExtension;

[DataContract]
internal class MyToolWindowData
{
    private Random random = new();

    public MyToolWindowData()
    {
        AddColorCommand = new AsyncCommand(async (parameter, cancellationToken) =>
        {
            await Task.Delay(TimeSpan.FromSeconds(2));

            var color = new byte[3];
            random.NextBytes(color);
            Colors.Add(new MyColor(color[0], color[1], color[2]));
        });
    }

    [DataMember]
    public ObservableList<MyColor> Colors { get; } = new();

    [DataMember]
    public AsyncCommand AddColorCommand { get; }

    [DataContract]
    public class MyColor
    {
        public MyColor(byte r, byte g, byte b)
        {
            ColorText = Color = $"#{r:X2}{g:X2}{b:X2}";
        }

        [DataMember]
        public string ColorText { get; }

        [DataMember]
        public string Color { get; }
    }
}

Il n’y a que quelques éléments remarquables dans ce code :

  • MyColor.Color est un string, mais il est utilisé comme Brush lorsque les données sont liées dans XAML. Il s'agit d'une fonctionnalité fournie par WPF.
  • Le AddColorCommand rappel asynchrone contient un délai de 2 secondes pour simuler une opération de longue durée.
  • Nous utilisons ObservableList<T>, qui est une ObservableCollection<T> étendue fournie par Remote UI pour prendre également en charge les opérations de plage, ce qui permet de meilleures performances.
  • MyToolWindowData et MyColor n’implémentent pas INotifyPropertyChanged car, à l’heure actuelle, toutes les propriétés sont en lecture seule.

Gérer les commandes asynchrones de longue durée

L’une des différences les plus importantes entre l’interface utilisateur distante et WPF normale est que toutes les opérations qui impliquent la communication entre l’interface utilisateur et l’extension sont asynchrones.

Commandes asynchrones telles que AddColorCommand rendre cela explicite en fournissant un rappel asynchrone.

Vous pouvez voir l’effet de ceci si vous cliquez sur le bouton Ajouter une couleur plusieurs fois dans un court délai : étant donné que chaque exécution de commande prend 2 secondes, plusieurs exécutions se produisent en parallèle et plusieurs couleurs apparaissent dans la liste ensemble lorsque le délai de 2 secondes est terminé. Cela peut donner l’impression à l’utilisateur que le bouton Ajouter une couleur ne fonctionne pas.

Diagramme de l’exécution de commandes asynchrones superposées.

Pour résoudre ce problème, désactivez le bouton pendant l’exécution de la commande asynchrone . La façon la plus simple d’effectuer cette opération consiste simplement à définir la commande CanExecute sur "false".

AddColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
    AddColorCommand!.CanExecute = false;
    try
    {
        await Task.Delay(TimeSpan.FromSeconds(2));
        var color = new byte[3];
        random.NextBytes(color);
        Colors.Add(new MyColor(color[0], color[1], color[2]));
    }
    finally
    {
        AddColorCommand.CanExecute = true;
    }
});

Cette solution présente encore une synchronisation imparfaite car, lorsque l'utilisateur clique sur le bouton, le rappel de commande est exécuté de manière asynchrone dans l'extension. Le rappel définit CanExecute sur false, ce qui est ensuite propagé de manière asynchrone au contexte de données proxy dans le processus Visual Studio, entraînant ainsi la désactivation du bouton. L’utilisateur peut cliquer deux fois sur le bouton en succession rapide avant la désactivation du bouton.

Une meilleure solution consiste à utiliser la RunningCommandsCount propriété des commandes asynchrones :

<Button Content="Add color" Command="{Binding AddColorCommand}" IsEnabled="{Binding AddColorCommand.RunningCommandsCount.IsZero}" Grid.Row="1" />

RunningCommandsCount est un compteur du nombre d’exécutions asynchrones simultanées de la commande en cours. Ce compteur est incrémenté sur le thread de l'interface utilisateur dès que le bouton est cliqué, ce qui permet de désactiver le bouton de manière synchrone en le liant IsEnabled à RunningCommandsCount.IsZero.

Étant donné que toutes les commandes d’interface utilisateur distantes s’exécutent de manière asynchrone, la meilleure pratique consiste à toujours utiliser RunningCommandsCount.IsZero pour désactiver les contrôles le cas échéant, même si la commande est censée s’exécuter rapidement.

Commandes asynchrones et modèles de données

Dans cette section, vous implémentez le bouton Supprimer , qui permet à l’utilisateur de supprimer une entrée de la liste. Nous pouvons créer une commande asynchrone pour chaque MyColor objet ou disposer d’une seule commande asynchrone et MyToolWindowData utiliser un paramètre pour identifier la couleur à supprimer. Cette dernière option est une conception plus propre. Nous allons donc implémenter cela.

  1. Mettez à jour le code XAML du bouton dans le modèle de données :
<Button Content="Remove" Grid.Column="2"
        Command="{Binding DataContext.RemoveColorCommand,
            RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListView}}}"
        CommandParameter="{Binding}"
        IsEnabled="{Binding DataContext.RemoveColorCommand.RunningCommandsCount.IsZero,
            RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListView}}}" />
  1. Ajoutez le correspondant AsyncCommand à MyToolWindowData:
[DataMember]
public AsyncCommand RemoveColorCommand { get; }
  1. Définissez le rappel asynchrone de la commande dans le constructeur de MyToolWindowData:
RemoveColorCommand = new AsyncCommand(async (parameter, ancellationToken) =>
{
    await Task.Delay(TimeSpan.FromSeconds(2));

    Colors.Remove((MyColor)parameter!);
});

Ce code utilise un Task.Delay pour simuler l'exécution longue d'une commande asynchrone.

Types de référence dans le contexte de données

Dans le code précédent, un MyColor objet est reçu en tant que paramètre d’une commande asynchrone et utilisé comme paramètre d’un List<T>.Remove appel, qui utilise l’égalité de référence (étant MyColor un type de référence qui ne remplace Equalspas ) pour identifier l’élément à supprimer. Cela est possible car, même si le paramètre est reçu de l’interface utilisateur, l’instance exacte de MyColor ce qui fait actuellement partie du contexte de données est reçue, et non une copie.

Les processus

  • de proxy du contexte de données d’un contrôle utilisateur distant ;
  • d’envoi des mises à jour INotifyPropertyChanged de l’extension vers Visual Studio ou inversement ;
  • d’envoi des mises à jour de collection observable de l’extension vers Visual Studio, ou inversement ;
  • d’envoi des paramètres de commande asynchrone.

tous respectent l’identité des objets de type référence. À l’exception des chaînes, les objets de type référence ne sont jamais dupliqués lors du transfert vers l’extension.

Diagramme des types de référence de liaison de données de l’interface utilisateur distante.

Dans l’image, vous pouvez voir comment chaque objet de type référence dans le contexte de données (les commandes, la collection, chacun MyColor et même le contexte de données entier) est affecté à un identificateur unique par l’infrastructure de l’interface utilisateur distante. Lorsque l’utilisateur clique sur le bouton Supprimer de l’objet de couleur proxy #5, l’identificateur unique (#5), et non la valeur de l’objet, est renvoyé à l’extension. L’infrastructure de l’interface utilisateur distante s’occupe de récupérer l’objet correspondant MyColor et de le transmettre en tant que paramètre au rappel de la commande asynchrone.

RunningCommandsCount avec plusieurs liaisons et gestion des événements

Si vous testez l’extension à ce stade, notez que lorsque l’un des boutons Supprimer est cliqué, tous les boutons Supprimer sont désactivés :

Diagramme de commande asynchrone avec plusieurs liaisons.

Il peut s’agir du comportement souhaité. Toutefois, supposons que vous souhaitez que seul le bouton actif soit désactivé et que vous autorisez l’utilisateur à mettre en file d’attente plusieurs couleurs pour la suppression : nous ne pouvons pas utiliser la propriété de la RunningCommandsCount, car nous avons une seule commande partagée entre tous les boutons.

Nous pouvons atteindre notre objectif en attachant une RunningCommandsCount propriété à chaque bouton afin que nous disposions d’un compteur distinct pour chaque couleur. Ces fonctionnalités sont fournies par l’espace de noms http://schemas.microsoft.com/visualstudio/extensibility/2022/xaml, qui permet d’utiliser des types d’interface utilisateur distante depuis XAML :

Nous modifions le bouton Supprimer en procédant comme suit :

<Button Content="Remove" Grid.Column="2"
        IsEnabled="{Binding Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero, RelativeSource={RelativeSource Self}}">
    <vs:ExtensibilityUICommands.EventHandlers>
        <vs:EventHandlerCollection>
            <vs:EventHandler Event="Click"
                             Command="{Binding DataContext.RemoveColorCommand, ElementName=RootGrid}"
                             CommandParameter="{Binding}"
                             CounterTarget="{Binding RelativeSource={RelativeSource Self}}" />
        </vs:EventHandlerCollection>
    </vs:ExtensibilityUICommands.EventHandlers>
</Button>

La vs:ExtensibilityUICommands.EventHandlers propriété jointe permet d’affecter des commandes asynchrones à n’importe quel événement (par exemple MouseRightButtonUp) et peut être utile dans des scénarios plus avancés.

vs:EventHandler peut également avoir un(e) CounterTarget : un(e) UIElement auquel(à laquelle) une propriété vs:ExtensibilityUICommands.RunningCommandsCount doit être attachée, liée à cet événement spécifique, en comptant les exécutions actives. Veillez à utiliser des parenthèses (par exemple Path=(vs:ExtensibilityUICommands.RunningCommandsCount).IsZero) lors de la liaison à une propriété jointe.

Dans ce cas, nous utilisons vs:EventHandler pour attacher à chaque bouton son propre compteur distinct d’exécutions de commandes actives. En liant IsEnabled à la propriété jointe, seul ce bouton spécifique est désactivé lorsque la couleur correspondante est supprimée :

Diagramme de commande asynchrone avec RunningCommandsCount ciblé.

Dictionnaires utilisateur de ressources XAML

À compter de Visual Studio 17.10, l’interface utilisateur distante prend en charge les dictionnaires de ressources XAML. Cela permet à plusieurs contrôles d’interface utilisateur distant de partager des styles, des modèles et d’autres ressources. Il vous permet également de définir différentes ressources (par exemple, des chaînes) pour différentes langues.

De même qu’un xaml de contrôle d’interface utilisateur distant, les fichiers de ressources doivent être configurés en tant que ressources incorporées :

<ItemGroup>
  <EmbeddedResource Include="MyResources.xaml" />
  <Page Remove="MyResources.xaml" />
</ItemGroup>

L’interface utilisateur distante référence les dictionnaires de ressources d’une manière différente de WPF : elles ne sont pas ajoutées aux dictionnaires fusionnés du contrôle (les dictionnaires fusionnés ne sont pas pris en charge du tout par l’interface utilisateur distante), mais référencées par nom dans le fichier .cs du contrôle :

internal class MyToolWindowContent : RemoteUserControl
{
    public MyToolWindowContent()
        : base(dataContext: new MyToolWindowData())
    {
        this.ResourceDictionaries.AddEmbeddedResource(
            "MyToolWindowExtension.MyResources.xaml");
    }
...

AddEmbeddedResource prend le nom complet de la ressource incorporée qui, par défaut, est composé de l’espace de noms racine du projet, de tout chemin d'accès de sous-dossier éventuel, et du nom du fichier. Il est possible de remplacer ce nom en définissant une LogicalName pour le EmbeddedResource dans le fichier de projet.

Le fichier de ressources lui-même est un dictionnaire de ressources WPF normal :

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:system="clr-namespace:System;assembly=mscorlib">
  <system:String x:Key="removeButtonText">Remove</system:String>
  <system:String x:Key="addButtonText">Add color</system:String>
</ResourceDictionary>

Vous pouvez référencer une ressource du dictionnaire de ressources dans le contrôle d'interface utilisateur à distance en utilisant DynamicResource.

<Button Content="{DynamicResource removeButtonText}" ...

Localisation des dictionnaires de ressources XAML

Les dictionnaires de ressources de l’interface utilisateur distante peuvent être localisés de la même façon que vous localisiez les ressources incorporées : vous créez d’autres fichiers XAML portant le même nom et un suffixe de langue, par exemple MyResources.it.xaml pour les ressources italiennes :

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:system="clr-namespace:System;assembly=mscorlib">
  <system:String x:Key="removeButtonText">Rimuovi</system:String>
  <system:String x:Key="addButtonText">Aggiungi colore</system:String>
</ResourceDictionary>

Vous pouvez utiliser des caractères génériques dans le fichier projet pour inclure tous les dictionnaires XAML localisés en tant que ressources incorporées :

<ItemGroup>
  <EmbeddedResource Include="MyResources.*xaml" />
  <Page Remove="MyResources.*xaml" />
</ItemGroup>

Utiliser des types WPF dans le contexte de données

Jusqu’à présent, le contexte de données de notre contrôle utilisateur distant a été composé de primitives (nombres, chaînes, etc.), de collections observables et de nos propres classes marquées par DataContract. Il est parfois utile d’inclure des types WPF simples dans le contexte de données, tels que des pinceaux complexes.

Étant donné qu’une extension VisualStudio.Extensibility ne peut même pas s’exécuter dans le processus Visual Studio, elle ne peut pas partager directement des objets WPF avec son interface utilisateur. L’extension n’a peut-être même pas accès aux types WPF, car elle peut cibler netstandard2.0 ou net6.0 (pas la -windows variante).

L’interface utilisateur distante fournit le XamlFragment type, qui permet d’inclure une définition XAML d’un objet WPF dans le contexte de données d’un contrôle utilisateur distant :

[DataContract]
public class MyColor
{
    public MyColor(byte r, byte g, byte b)
    {
        ColorText = $"#{r:X2}{g:X2}{b:X2}";
        Color = new(@$"<LinearGradientBrush xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
                               StartPoint=""0,0"" EndPoint=""1,1"">
                           <GradientStop Color=""Black"" Offset=""0.0"" />
                           <GradientStop Color=""{ColorText}"" Offset=""0.7"" />
                       </LinearGradientBrush>");
    }

    [DataMember]
    public string ColorText { get; }

    [DataMember]
    public XamlFragment Color { get; }
}

Avec le code ci-dessus, la Color valeur de propriété est convertie en objet LinearGradientBrush dans le proxy de contexte de données : capture d’écran montrant les types WPF dans le contexte de données

Interface utilisateur distante et threads

Les rappels de commande asynchrone (et les rappels INotifyPropertyChanged pour les valeurs mises à jour par l’interface utilisateur via la liaison de données) sont déclenchés sur des threads aléatoires du pool de threads. Les rappels sont déclenchés un par un et ne se chevauchent pas tant que le code ne cède pas le contrôle (à l’aide d’une expression await).

Ce comportement peut être modifié en passant un NonConcurrentSynchronizationContext au RemoteUserControl constructeur. Dans ce cas, vous pouvez utiliser le contexte de synchronisation fourni pour toutes les commandes asynchrones et INotifyPropertyChanged rappels liés à ce contrôle.