Tag Archives: winrt

[WinRT] Sélectionner un élément de liste lors d’un appui long

Comme vous le savez probablement, la méthode standard pour sélectionner ou déselectionner un élément dans un contrôle de liste WinRT est de le faire glisser légèrement vers le haut ou vers le bas. Même si j’aime bien ce geste, il n’est pas très intuitif pour les utilisateurs qui ne sont pas habitués à Modern UI. Et ça devient encore plus déroutant, car ma déclaration précédente n’est pas tout à fait exacte : en fait, il faut faire glisser l’élément perpendiculairement à la direction de défilement de la liste. Dans une GridView, qui défile horizontalement (par défaut), c’est vers le haut ou vers le bas ; mais pour une ListView, qui défile verticalement, il faut faire glisser l’élément vers la gauche ou vers la droite. Si une application utilise les deux types de liste, ça devient vraiment très déroutant pour l’utilisateur.

Bien sûr, dans le style par défaut, il y a une indication visuelle (une discrète animation “glissement vers le bas” avec une coche grise) quand l’utilisateur appuie longuement sur un élément, mais ce n’est pas toujours suffisant pour que tout le monde comprenne. Beaucoup de gens (par exemple les utilisateurs d’Android) on l’habitude de sélectionner les éléments par un appui long (appelé “Hold” dans la terminologie Modern UI). Donc, pour rendre votre application facilement utilisable par le plus grand nombre d’utilisateurs, il peut être intéressant de permettre la sélection par un appui long.

Un moyen simple de le faire est de créer une propriété attachée qui, quand on la met à true, s’abonne à l’évènement Holding de l’élément, et change la valeur de la propriété IsSelected quand l’évènement se produit. Voilà une implémentation possible :

using Windows.UI.Input;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Input;

namespace TestSelectOnHold
{
    public static class SelectorItemEx
    {
        public static bool GetToggleSelectedOnHold(SelectorItem item)
        {
            return (bool)item.GetValue(ToggleSelectedOnHoldProperty);
        }

        public static void SetToggleSelectedOnHold(SelectorItem item, bool value)
        {
            item.SetValue(ToggleSelectedOnHoldProperty, value);
        }

        public static readonly DependencyProperty ToggleSelectedOnHoldProperty =
            DependencyProperty.RegisterAttached(
              "ToggleSelectedOnHold",
              typeof(bool),
              typeof(SelectorItemEx),
              new PropertyMetadata(
                false,
                ToggleSelectedOnHoldChanged));

        private static void ToggleSelectedOnHoldChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
        {
            var item = o as SelectorItem;
            if (item == null)
                return;

            var oldValue = (bool)e.OldValue;
            var newValue = (bool)e.NewValue;

            if (oldValue && !newValue)
            {
                item.Holding -= Item_Holding;
            }
            else if (newValue && !oldValue)
            {
                item.Holding += Item_Holding;
            }
        }

        private static void Item_Holding(object sender, HoldingRoutedEventArgs e)
        {
            var item = sender as SelectorItem;
            if (item == null)
                return;

            if (e.HoldingState == HoldingState.Started)
                item.IsSelected = !item.IsSelected;
        }
    }
}

Vous pouvez ensuite définir cette propriété dans l’ItemContainerStyle du contrôle de liste :

<GridView.ItemContainerStyle>
    <Style TargetType="GridViewItem">
        ...
        <Setter Property="local:SelectorItemEx.ToggleSelectedOnHold" Value="False" />
    </Style>
</GridView.ItemContainerStyle>

Et c’est tout : l’utilisateur peut maintenant sélectionner les éléments par un appui long. Le geste standard fonctionne toujours, bien sûr, donc les utilisateurs qui le connaissent peuvent toujours l’utiliser.

Notez que cette fonctionnalité aurait aussi pu être implémentée comme un Behavior à part entière. Il y a deux raisons pour lesquelles je n’ai pas choisi cette approche :

  • Les Behaviors ne sont pas supportés nativement dans WinRT (bien qu’on puisse les ajouter avec un package Nuget)
  • Les Behaviors ne fonctionnent pas très bien avec les styles, parce que Interaction.Behaviors est une collection, et on ne peut pas ajouter des éléments à une collection dans un style. Une solution possible pour contourner le problème serait de créer une propriété attachée IsEnabled, qui ajouterait le behavior à la collection quand on la met à true, mais on se retrouverait finalement avec une solution quasiment identique à celle décrite plus haut, en plus complexe…

Helper fortement typé pour les notifications toast

Windows 8 fournit une API pour afficher des notifications toast. Malheureusement, elle est très peu pratique à utiliser : pour définir le contenu d’une notification, il faut utiliser un modèle prédéfini qui est fourni sous la forme d’un XmlDocument, et fixer la valeur de chaque champ dans le XML. Il n’y a rien dans l’API pour indiquer quels champs sont définis dans le modèle et à quoi ils correspondent, il faut consulter le catalogue de modèles de toast dans la documentation. Il serait beaucoup plus pratique d’avoir une API fortement typée…

J’ai donc créé un simple wrapper autour de l’API des toasts. On peut l’utiliser comme ceci :

var content = new ToastContent.ImageAndText02
{
    Image = "ms-appx:///Images/dotnet.png",
    Title = "Hello world!",
    Text = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
};
var notifier = ToastNotificationManager.CreateToastNotifier();
notifier.Show(content.CreateNotification());

Notez que j’ai gardé les noms d’origine du catalogue de modèles, parce que des noms suffisamment descriptifs auraient été trop longs. J’ai inclus des commentaires XML de documentation pour faciliter le choix du modèle.

Si vous voulez plus de flexibilité qu’un modèle fortement typé ne peut en offrir, mais que vous ne voulez pas manipuler le XML du modèle, vous pouvez utiliser la classe ToastContent directement :

var content = new ToastContent(ToastTemplateType.ToastImageAndText02);
content.SetImage(1, "ms-appx:///Images/dotnet.png");
content.SetText(1, "Hello world!");
content.SetText(2, "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.");
var notifier = ToastNotificationManager.CreateToastNotifier();
notifier.Show(content.CreateNotification());

Le code est disponible sur Github, avec une application de démo. Un package NuGet est également disponible.

Un point intéressant est la façon dont j’ai créé les classes de modèle : j’aurais pu le faire à la main, mais cela aurait été assez fastidieux. J’ai donc extrait les modèles de toast dans un fichier XML, je l’ai enrichi de quelques informations supplémentaires (noms des propriétés, description pour les commentaires de documentation), et j’ai créé un template T4 pour générer automatiquement les classes à partir du fichier XML.

Afficher des suggestions de résultat dans une SearchBox WinRT : bug concernant l’image

Aujourd’hui je me suis heurté à un bug bizarre qui m’a fait perdre une heure ou deux, donc je me suis dit que ça méritait d’écrire un billet à ce sujet au cas où quelqu’un d’autre rencontrerait le même problème.

Le contrôle SearchBox a été ajouté dans Windows 8.1 pour permettre des scénarios de recherche directement dans une application Windows Store. L’une de ses fonctionnalités est l’affichage de suggestions basées sur la saisie de l’utilisateur. Il y a trois sortes de suggestions :

  • Les suggestions d’historique sont les requêtes précédemment effectuées par l’utilisateur. C’est géré automatiquement, donc vous n’avez aucun code à écrire pour que ça marche.
  • Les suggestions de recherche permettent de proposer des termes de recherche en fonction de ce que l’utilisateur a déjà saisi ; si l’utilisateur en sélectionne une, le texte actuel de la recherche est remplacé par celui de la suggestion, et valider la requête lancera la recherche avec ce texte.
  • Les suggestions de résultat sont des suggestions pour des résultats exacts. L’utilisateur peut directement choisir un de ces résultats sans lancer une recherche complète.

Pour fournir des suggestions, il faut gérer l’évènement SuggestionsRequested de la SearchBox, et ajouter des suggestions à l’aide des méthodes AppendQuerySuggestion et AppendResultSuggestion. Concentrons-nous sur les suggestions de résultat.

La méthode AppendResultSuggestion prend plusieurs paramètres, dont l’un représente l’image à afficher pour la suggestion. Il est obligatoire (passer null lèvera une exception), et il est de type IRandomAccessStreamReference, c’est-à-dire quelque chose qui peut fournir un flux. Je trouve ça un peu étrange, vu qu’il aurait été plus naturel de passer une ImageSource, mais c’est comme ça… J’ai donc cherché une classe qui implémente l’interface IRandomAccessStreamReference, et le premier candidat évident que j’ai trouvé était la classe StorageFile, qui représente un fichier. J’ai donc écrit le code suivant :

private async void SearchBox_SuggestionsRequested(SearchBox sender, SearchBoxSuggestionsRequestedEventArgs args)
{
    var deferral = args.Request.GetDeferral();
    try
    {
        var imageUri = new Uri("ms-appx:///test.png");
        var imageRef = await StorageFile.GetFileFromApplicationUriAsync(imageUri);
        args.Request.SearchSuggestionCollection.AppendQuerySuggestion("test");
        args.Request.SearchSuggestionCollection.AppendSearchSeparator("Foo Bar");
        args.Request.SearchSuggestionCollection.AppendResultSuggestion("foo", "Details", "foo", imageRef, "Result");
        args.Request.SearchSuggestionCollection.AppendResultSuggestion("bar", "Details", "bar", imageRef, "Result");
        args.Request.SearchSuggestionCollection.AppendResultSuggestion("baz", "Details", "baz", imageRef, "Result");
    }
    finally
    {
        deferral.Complete();
    }
}

Ce code s’exécute sans aucune erreur, et les suggestions sont affichées… mais l’image n’apparait pas !

https://i2.wp.com/i.stack.imgur.com/BiF0g.png?w=474

J’ai passé un long moment à tout revérifier, à faire plein de petits changements pour essayer de trouver l’origine du problème, j’ai même fait ma propre implémentation de IRandomAccessStreamReference… en vain.

J’ai finalement posté mon problème sur Stack Overflow, et quelqu’un m’a gentiment fourni la solution, qui était très simple : au lieu d’utiliser StorageFile, il faut utiliser RandomAccessStreamReference (ça semble assez évident une fois qu’on sait que ça existe). Le code devient donc :

private void SearchBox_SuggestionsRequested(SearchBox sender, SearchBoxSuggestionsRequestedEventArgs args)
{
    var imageUri = new Uri("ms-appx:///test.png");
    var imageRef = RandomAccessStreamReference.CreateFromUri(imageUri);
    args.Request.SearchSuggestionCollection.AppendQuerySuggestion("test");
    args.Request.SearchSuggestionCollection.AppendSearchSeparator("Foo Bar");
    args.Request.SearchSuggestionCollection.AppendResultSuggestion("foo", "Details", "foo", imageRef, "Result");
    args.Request.SearchSuggestionCollection.AppendResultSuggestion("bar", "Details", "bar", imageRef, "Result");
    args.Request.SearchSuggestionCollection.AppendResultSuggestion("baz", "Details", "baz", imageRef, "Result");
}

(Notez que la méthode n’est plus asynchrone, il n’y a donc plus besoin d’utiliser l’objet deferral).

Les suggestions sont maintenant affichées comme je le voulais, avec l’image :

https://i1.wp.com/i.imgur.com/cjmogKp.png?w=474

La leçon à tirer de cette histoire est que, bien que le paramètre image soit de type IRandomAccessStreamReference, il ne semble pas accepter autre chose qu’une instance de la classe RandomAccessStreamReference. Si vous passez n’importe quelle autre implémentation de l’interface, cela échoue silencieusement et l’image n’est pas affichée. C’est clairement un bug : si le type déclaré du paramètre dans la signature de la méthode et une interface, la méthode devrait accepter n’importe quelle implémentation de cette interface ; sinon, la signature devrait déclarer le type concret. J’ai signalé le bug sur Connect, avec un peu de chance ce sera corrigé dans une future version.

En espérant que ce soit utile à quelqu’un !

Détecter les changements d’une propriété de dépendance dans WinRT

Aujourd’hui j’aimerais partager une astuce que j’ai utilisée en développant ma première application Windows Store. Je suis complètement nouveau sur cette technologie et c’est mon premier billet à ce sujet, donc j’espère que je ne vais pas trop me ridiculiser…

Il est souvent utile d’être notifié quand la valeur d’une propriété de dépendance change ; beaucoup de contrôles exposent des évènements à cet effet, mais ce n’est pas toujours le cas. Par exemple, récemment j’essayais de détecter les changements de la propriété Content d’un ContentControl. En WPF, j’aurais utilisé la classe DependencyPropertyDescriptor, mais elle n’est pas disponible dans WinRT.

Heureusement, il y a un mécanisme qui existe sur toutes les plateformes XAML, et qui peut résoudre ce problème: le binding. La solution est donc simplement de créer une classe avec un propriété “bidon” qui est liée à la propriété qu’on souhaite observer, et d’appeler un handler quand la valeur de cette propriété bidon change. Pour rendre ça un peu plus propre et masquer l’implémentation réelle, j’ai emballé ça sous forme d’une méthode d’extension qui renvoie un IDisposable:

    public static class DependencyObjectExtensions
    {
        public static IDisposable WatchProperty(this DependencyObject target,
                                                string propertyPath,
                                                DependencyPropertyChangedEventHandler handler)
        {
            return new DependencyPropertyWatcher(target, propertyPath, handler);
        }

        class DependencyPropertyWatcher : DependencyObject, IDisposable
        {
            private DependencyPropertyChangedEventHandler _handler;

            public DependencyPropertyWatcher(DependencyObject target,
                                             string propertyPath,
                                             DependencyPropertyChangedEventHandler handler)
            {
                if (target == null) throw new ArgumentNullException("target");
                if (propertyPath == null) throw new ArgumentNullException("propertyPath");
                if (handler == null) throw new ArgumentNullException("handler");

                _handler = handler;

                var binding = new Binding
                {
                    Source = target,
                    Path = new PropertyPath(propertyPath),
                    Mode = BindingMode.OneWay
                };
                BindingOperations.SetBinding(this, ValueProperty, binding);
            }

            private static readonly DependencyProperty ValueProperty =
                DependencyProperty.Register(
                    "Value",
                    typeof(object),
                    typeof(DependencyPropertyWatcher),
                    new PropertyMetadata(null, ValuePropertyChanged));

            private static void ValuePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
            {
                var watcher = d as DependencyPropertyWatcher;
                if (watcher == null)
                    return;

                watcher.OnValueChanged(e);
            }

            private void OnValueChanged(DependencyPropertyChangedEventArgs e)
            {
                var handler = _handler;
                if (handler != null)
                    handler(this, e);
            }

            public void Dispose()
            {
                _handler = null;
                // There is no ClearBinding method, so set a dummy binding instead
                BindingOperations.SetBinding(this, ValueProperty, new Binding());
            }
        }
    }

On peut l’utiliser comme ceci:

// Abonnement
watcher = myControl.WatchProperty("Content", myControl_ContentChanged);

// Désabonnement
watcher.Dispose();

J’espère que vous trouverez cela utile!