[WPF] Afficher une image GIF animée

Très mauvaisMauvaisMoyenBonExcellent (6 votes) 
Loading ... Loading ...

WPF est une technologie géniale, mais parfois on a l’impression qu’il lui manque certaines fonctionnalités assez basiques… Un exemple souvent cité est l’absence de support pour les images GIF animées. En fait, le format GIF proprement dit est supporté, mais le contrôle Image n’affiche que la première image de l’animation.

De nombreuses solutions à ce problème ont été proposées sur les forums et blogs techniques, généralement des variantes autour des approches suivantes :

  • Utiliser le contrôle MediaElement : malheureusement ce contrôle ne supporte que les URI de type file:// ou http://, et non le schéma d’URI pack:// utilisé pour les ressources WPF ; l’image ne peut donc pas être inclue dans les ressources, elle doit être dans un fichier à part. De plus, la transparence n’est pas supportée, si bien que le résultat final est assez laid
  • Utiliser le contrôle PictureBox de Windows Forms, via un WindowsFormsHost : personnellement j’ai horreur d’utiliser des contrôles Windows Forms en WPF, ça me donne l’impression de faire quelque chose de mal :P
  • Créer un contrôle dérivé de Image qui gère l’animation. Pour l’implémentation, certaines solutions tirent partie de la classe ImageAnimator de System.Drawing (GDI), d’autres utilisent une animation WPF pour changer de frame. C’est une approche assez “propre”, mais qui oblige à utiliser un contrôle spécifique pour les GIF. De plus la solution utilisant ImageAnimator se révèle assez peu fluide.

Comme vous l’aurez deviné, aucune de ces solutions ne me satisfait vraiment… De plus, aucune ne gère proprement la durée de chaque frame, et suppose simplement que toutes les frames durent 100ms (c’est presque toujours le cas, mais le presque fait toute la différence…). Je n’ai donc gardé que les meilleures idées dans les approches ci-dessus pour créer ma propre solution. Les objectifs que je souhaitais atteindre sont les suivants :

  • Ne pas dépendre de Windows Forms ou de GDI
  • Afficher l’image animée dans un contrôle Image standard
  • Pouvoir utiliser le même code XAML pour une image fixe ou animée
  • Supporter la transparence
  • Tenir compte de la durée réelle de chaque frame de l’image

Pour arriver à ce résultat, je suis parti d’une idée simple, voire évidente : pour animer l’image, il suffit d’appliquer une animation à la propriété Source du contrôle Image. Or WPF fournit tous les outils nécessaires pour réaliser ce type d’animation ; en l’occurrence la classe ObjectAnimationUsingKeyFrames répond parfaitement au besoin : on peut spécifier à quel instant exact affecter une valeur donnée à la propriété, ce qui permet de tenir compte de la durée des frames.

Le problème suivant est d’extraire les différentes frames de l’image : heureusement ce scénario est prévu dans WPF, et la classe BitmapDecoder fournit une propriété Frames qui sert à ça. Donc, pas de difficulté majeure à ce niveau…

Enfin, dernier obstacle : extraire la durée de chaque frame. C’est finalement la partie qui m’a demandé le plus de recherche… J’ai d’abord cru qu’il faudrait lire manuellement le fichier pour trouver cette information, en décodant directement les données binaires. Mais la solution est finalement assez simple, et tire partie de la classe BitmapMetadata. La seule difficulté a été de localiser le “chemin” de la métadonnée qui contient cette information, mais après quelques tâtonnements, la voilà : /grctlext/Delay.

La solution finale est implémentée sous forme d’une propriété attachée AnimatedSource applicable au contrôle Image, qui s’utilise en lieu et place de Source :

<Image Stretch="None" my:ImageBehavior.AnimatedSource="/Images/animation.gif" />

On peut également affecter une image fixe à cette propriété, elle s’affichera normalement ; on peut donc utiliser cette propriété sans se soucier de savoir si l’image à afficher sera fixe ou animée.

Au final, tous les objectifs fixés au départ sont donc atteints, et il y a même une cerise sur le gâteau : cette solution fonctionne également dans le designer (du moins dans Visual Studio 2010), on voit donc directement l’animation quand on affecte la propriété AnimatedSource :)

Sans plus attendre, voilà le code complet :

    public static class ImageBehavior
    {
        #region AnimatedSource

        [AttachedPropertyBrowsableForType(typeof(Image))]
        public static ImageSource GetAnimatedSource(Image obj)
        {
            return (ImageSource)obj.GetValue(AnimatedSourceProperty);
        }

        public static void SetAnimatedSource(Image obj, ImageSource value)
        {
            obj.SetValue(AnimatedSourceProperty, value);
        }

        public static readonly DependencyProperty AnimatedSourceProperty =
            DependencyProperty.RegisterAttached(
              "AnimatedSource",
              typeof(ImageSource),
              typeof(ImageBehavior),
              new UIPropertyMetadata(
                null,
                AnimatedSourceChanged));

        private static void AnimatedSourceChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
        {
            Image imageControl = o as Image;
            if (imageControl == null)
                return;

            var oldValue = e.OldValue as ImageSource;
            var newValue = e.NewValue as ImageSource;
            if (oldValue != null)
            {
                imageControl.BeginAnimation(Image.SourceProperty, null);
            }
            if (newValue != null)
            {
                imageControl.DoWhenLoaded(InitAnimationOrImage);
            }
        }

        private static void InitAnimationOrImage(Image imageControl)
        {
            BitmapSource source = GetAnimatedSource(imageControl) as BitmapSource;
            if (source != null)
            {
                var decoder = GetDecoder(source) as GifBitmapDecoder;
                if (decoder != null && decoder.Frames.Count > 1)
                {
                    var animation = new ObjectAnimationUsingKeyFrames();
                    var totalDuration = TimeSpan.Zero;
                    BitmapSource prevFrame = null;
                    FrameInfo prevInfo = null;
                    foreach (var rawFrame in decoder.Frames)
                    {
                        var info = GetFrameInfo(rawFrame);
                        var frame = MakeFrame(
                            source,
                            rawFrame, info,
                            prevFrame, prevInfo);

                        var keyFrame = new DiscreteObjectKeyFrame(frame, totalDuration);
                        animation.KeyFrames.Add(keyFrame);
                        
                        totalDuration += info.Delay;
                        prevFrame = frame;
                        prevInfo = info;
                    }
                    animation.Duration = totalDuration;
                    animation.RepeatBehavior = RepeatBehavior.Forever;
                    if (animation.KeyFrames.Count > 0)
                        imageControl.Source = (ImageSource)animation.KeyFrames[0].Value;
                    else
                        imageControl.Source = decoder.Frames[0];
                    imageControl.BeginAnimation(Image.SourceProperty, animation);
                    return;
                }
            }
            imageControl.Source = source;
            return;
        }

        private static BitmapDecoder GetDecoder(BitmapSource image)
        {
            BitmapDecoder decoder = null;
            var frame = image as BitmapFrame;
            if (frame != null)
                decoder = frame.Decoder;

            if (decoder == null)
            {
                var bmp = image as BitmapImage;
                if (bmp != null)
                {
                    if (bmp.StreamSource != null)
                    {
                        decoder = BitmapDecoder.Create(bmp.StreamSource, bmp.CreateOptions, bmp.CacheOption);
                    }
                    else if (bmp.UriSource != null)
                    {
                        Uri uri = bmp.UriSource;
                        if (bmp.BaseUri != null && !uri.IsAbsoluteUri)
                            uri = new Uri(bmp.BaseUri, uri);
                        decoder = BitmapDecoder.Create(uri, bmp.CreateOptions, bmp.CacheOption);
                    }
                }
            }

            return decoder;
        }

        private static BitmapSource MakeFrame(
            BitmapSource fullImage,
            BitmapSource rawFrame, FrameInfo frameInfo,
            BitmapSource previousFrame, FrameInfo previousFrameInfo)
        {
            DrawingVisual visual = new DrawingVisual();
            using (var context = visual.RenderOpen())
            {
                if (previousFrameInfo != null && previousFrame != null &&
                    previousFrameInfo.DisposalMethod == FrameDisposalMethod.Combine)
                {
                    var fullRect = new Rect(0, 0, fullImage.PixelWidth, fullImage.PixelHeight);
                    context.DrawImage(previousFrame, fullRect);
                }

                context.DrawImage(rawFrame, frameInfo.Rect);
            }
            var bitmap = new RenderTargetBitmap(
                fullImage.PixelWidth, fullImage.PixelHeight,
                fullImage.DpiX, fullImage.DpiY,
                PixelFormats.Pbgra32);
            bitmap.Render(visual);
            return bitmap;
        }

        private class FrameInfo
        {
            public TimeSpan Delay { get; set; }
            public FrameDisposalMethod DisposalMethod { get; set; }
            public double Width { get; set; }
            public double Height { get; set; }
            public double Left { get; set; }
            public double Top { get; set; }

            public Rect Rect
            {
                get { return new Rect(Left, Top, Width, Height); }
            }
        }

        private enum FrameDisposalMethod
        {
            Replace = 0,
            Combine = 1,
            RestoreBackground = 2,
            RestorePrevious = 3
        }

        private static FrameInfo GetFrameInfo(BitmapFrame frame)
        {
            var frameInfo = new FrameInfo
            {
                Delay = TimeSpan.FromMilliseconds(100),
                DisposalMethod = FrameDisposalMethod.Replace,
                Width = frame.PixelWidth,
                Height = frame.PixelHeight,
                Left = 0,
                Top = 0
            };

            BitmapMetadata metadata;
            try
            {
                metadata = frame.Metadata as BitmapMetadata;
                if (metadata != null)
                {
                    const string delayQuery = "/grctlext/Delay";
                    const string disposalQuery = "/grctlext/Disposal";
                    const string widthQuery = "/imgdesc/Width";
                    const string heightQuery = "/imgdesc/Height";
                    const string leftQuery = "/imgdesc/Left";
                    const string topQuery = "/imgdesc/Top";

                    var delay = metadata.GetQueryOrNull<ushort>(delayQuery);
                    if (delay.HasValue)
                        frameInfo.Delay = TimeSpan.FromMilliseconds(10 * delay.Value);

                    var disposal = metadata.GetQueryOrNull<byte>(disposalQuery);
                    if (disposal.HasValue)
                        frameInfo.DisposalMethod = (FrameDisposalMethod) disposal.Value;

                    var width = metadata.GetQueryOrNull<ushort>(widthQuery);
                    if (width.HasValue)
                        frameInfo.Width = width.Value;

                    var height = metadata.GetQueryOrNull<ushort>(heightQuery);
                    if (height.HasValue)
                        frameInfo.Height = height.Value;

                    var left = metadata.GetQueryOrNull<ushort>(leftQuery);
                    if (left.HasValue)
                        frameInfo.Left = left.Value;

                    var top = metadata.GetQueryOrNull<ushort>(topQuery);
                    if (top.HasValue)
                        frameInfo.Top = top.Value;
                }
            }
            catch (NotSupportedException)
            {
            }

            return frameInfo;
        }

        private static T? GetQueryOrNull<T>(this BitmapMetadata metadata, string query)
            where T : struct
        {
            if (metadata.ContainsQuery(query))
            {
                object value = metadata.GetQuery(query);
                if (value != null)
                    return (T) value;
            }
            return null;
        }

        #endregion
    }

Et voici la méthode d’extension DoWhenLoaded utilisée dans le code ci-dessus :

public static void DoWhenLoaded<T>(this T element, Action<T> action)
    where T : FrameworkElement
{
    if (element.IsLoaded)
    {
        action(element);
    }
    else
    {
        RoutedEventHandler handler = null;
        handler = (sender, e) =>
        {
            element.Loaded -= handler;
            action(element);
        };
        element.Loaded += handler;
    }
}

Cette classe sera inclue dans la prochaine version de la librairie Dvp.NET, dont j’avais déjà parlé il y quelque temps.

Mise à jour : le code qui récupère la durée d’une frame ne fonctionne que sous Windows Seven, et sous Windows Vista si la Platform Update est installée (non testé). La durée par défaut (100ms) sera utilisée sur les autres versions de Windows. Je mettrai à jour l’article si je trouve une solution qui fonctionne sur tous les systèmes (je sais que je pourrais utiliser System.Drawing.Bitmap, mais je préfèrerais éviter…)

Mise à jour 2 : comme Klaus l’a signalé dans un commentaire sur la version anglaise de mon blog, la classe ImageBehavior ne gérait pas certains attributs importants des frames : la méthode de destruction (est-ce qu’une frame doit simplement remplacer la frame précédente, ou être combinée avec elle), et la position des frames (Left/Top/Width/Height). J’ai mis à jour le code pour gérer ces attributs correctement. Merci Klaus !

Mise à jour 3 : encore un petit bug corrigé, la récupération du décodeur à partir d’une URI relative ne fonctionnait pas. Merci à l’anonyme qui l’a signalé!

Mise à jour 4 : plutôt que de continuer à poster les améliorations sur ce billet, j’ai finalement créé un projet sur CodePlex où cette classe sera maintenue. Vous pouvez aussi l’installer avec NuGet, l’id du package est WpfAnimatedGif. Merci à Diego Mijelshon pour la suggestion!

31 Comments

  1. Myriam says:

    Bonsoir,
    votre article m”a informé de ce problème avec les gif. Merci. Par contre est ce que vous avez la version c# de votre code ? ça m”intéresserait en fait.

    Je vous remercie ;)

    • Bonjour Myriam,

      Je ne suis pas sûr de comprendre… le code de la propriété attachée est en C#, de quel autre code parlez-vous ? Si vous voulez utiliser la propriété attachée à partir de C#, c”est très simple, il suffit d”utiliser la méthode statique SetAnimatedSource :

      ImageSource gifImage = ...
      ImageBehavior.SetAnimatedSource(imageControl, gifImage);
      
  2. Myriam says:

    Oui pardon je me suis mal exprimée !
    Désolée! C”est cette réponse que je voulais, merci beaucoup, ça va me rendre un grand service :)
    Par contre je suis surprise tout de même que WPF ne peut pas gérer les gif comme windowforms… c”est quand même soit disant la nouvelle version, et ce n”est pas très au point !

  3. Steve says:

    Bonsoir,
    Thomas, j”essaye en vain de convertir ce code en VB .NET car je ne maitrise pas assez le c#… mais j”ai un peu de mal. Aurais-tu par hasard la version vb.NET ?

    Merci beaucoup !!

  4. Steve says:

    Merci pour ta réponse rapide !
    Je viens de faire la conversion et voici deux erreurs que j”obtiens :

    -> dans le XAML, j”insère cette ligne dans mon Grid :

    et ça m”indique “La propriété pouvant être attachée ”AnimatedSource” est introuvable dans le type ”ImageBehavior”

    -> dans le code VB.NET, cette ligne “imageControl.DoWhenLoaded(AddressOf InitAnimationOrImage)” m”indique une erreur : “DoWhenLoaded” n”est pas un membre de ”System.Windows.Controls.Image”

    ça te parle ça ?
    Merci !

    • Pour la 2e erreur, il faut déclarer la méthode DoWhenLoaded (code à la fin de l”article) dans une classe statique (ou peut-être un module en VB, je suis pas sûr)
      L”erreur dans le XAML disparaitra quand l”autre sera corrigée

  5. Steve says:

    Re !
    Je viens de créer un projet en c# tout de même pour voir et j”ai la même erreur avec cette ligne :
    “Image Stretch=”None” my:ImageBehavior.AnimatedSource=”/Images/animation.gif”

    La propriété pouvant être attachée ‘AnimatedSource’ est introuvable dans le type ‘ImageBehavior’

    Si il faut c”est tout bête mais je débute….

  6. Steve says:

    J”ai essayé ceci : xmlns:custom=”clr-namespace:ImageBehavior;assembly=SDKSampleLibrary”
    mais ça ne fonctionne pas…Quelle est l”assembly ?!

    • C”est le namespace où est déclarée la classe ImageBehavior qu”il faut mettre, pas le nom de la classe. Pour l”assembly, c”est habituellement le nom du projet (on peut omettre la partie assembly si la classe est dans le même projet)

  7. Steve says:

    Bon rien à faire… je rends mes armes, je verrai ça demain !

  8. Anonymous says:

    Bonjour Thomas,
    J”ai essayé ton code, il marche très bien mais j”avais une petite remarque.
    Avant toutes choses je voulais te signaler que le contenu de la fonction Image.DoWhenLoaded() n”est présente que sur la version anglaise de ton blog.
    D”autre part, je voulais te dire que ton code fonctionne très bien lorsque l”on précise directement le chemin du fichier dans l”attribut my:ImageBehavior.AnimatedSource. Par contre, lorsque l”on place son image dans un dictionnaire et qu”on utilise l”attribut de la manière suivante my:ImageBehavior.AnimatedSource=”{StaticResource keyNameOfMyGif}” le code plante dans la fonction ImageBehavior.GetDecoder qui n”arrive pas à trouver le chemin exact du fichier (il le cherche dans /bin/Debug/).

    Voilà, après moi je suis encore débutant dans WPF. Peut être que ce n”est pas un good practice de mettre des images dans un dictionnaire. Enfin bon, j”espère avoir été utile :).

    • Merci pour le commentaire, j”ai mis à jour le billet pour ajouter la méthode DoWhenLoaded qui manquait.

      Je vais jeter un coup d”oeil au problème que tu indiques ; comment as-tu déclaré l”image dans les ressources ? Avec la classe BitmapImage ?

    • OK, problème identifié et corrigé ; le code ne tenait pas compte de la BaseUri, et donc ça ne fonctionnait pas avec une URI relative.
      Merci d”avoir signalé le problème !

  9. Tonanus says:

    J”ai testé et c”est bien ça marche.
    Juste un petit détail, normalement les gifs ont déjà une propriété de boucle intégrée.
    Ca serait bien que si ont met RepeatBehavior=”Default” ça la respecte.
    Voilà. :)

    • Thomas Levesque says:

      Bonjour,

      Effectivement, ça m”avait échappé car ça ne fait pas partie de la spécification GIF89a, c”est en fait une extension de Netscape (bien que ce soit maintenant supporté par la plupart des implémentations). Je vais voir ce que je peux faire pour supporter ça.

    • Thomas Levesque says:

      J”ai publié une nouvelle version (sur CodePlex et sur Nuget) qui implémente ça.

      On ne peut pas mettre RepeatBehavior="Default", car ce n”est pas une valeur valide pour le type RepeatBehavior, mais si on ne spécifie pas de valeur pour cette propriété ça utilise la valeur indiquée dans le GIF.

      • Tonanus says:

        Rebonjour, enfin bonne nuit plutôt.
        Finalement avoir une extension ne me plaisait pas trop, du coup je me suis amusé à récrire le code dans une classe dérivée. :)
        Et en fait je viens pas pour raconter ma vie mais parce que j”ai trouvé une petite possibilité d”optimisation.
        Dans la fonction MakeFrame, si jamais la frame est en mode Replace et que son Rect fait toute la taille de l”image on peut retourner directement la rawFrame.
        Et puis sur les bitmap créés un Freeze() serait le bienvenu.

        • Thomas Levesque says:

          Justement moi je préférais utiliser le contrôle Image standard plutôt qu”une classe dérivée, d”où l”idée de la propriété attachée… chacun ses gouts ;)

          Les optimisations proposées me semblent effectivement judicieuses, je vais essayer de les intégrer à ma prochaine version. Merci pour la suggestion !

          • Tonanus says:

            Tiens tant que j”y suis, hop : http://pastebin.com/rUREScCn
            Comme ça si ça intéresse quelqu”un qui passe dans le coin.
            Voilà, merci bien pour le code source il m”a été très utile.

          • Thomas Levesque says:

            Ah, justement je me demandais comment récupérer le RepeatBehavior dans les métadonnées via l”API de WPF… merci pour le tuyau ! Enfin de toutes façons je n”utilise plus que mon décodeur “manuel”, vu que l”API de métadonnées WPF ne fonctionne pas sous XP…

          • Tonanus says:

            Ah, oui, dans mes recherches j”étais tombé sur un topic qui parlait de ça, d”un certain Thomas Levesque.
            J”avais par la suite refait la classe en utilisant System.Drawing, c”était très propre, le code faisait 100 lignes de moins j”étais content mais bon c”était moins performant et puis après coup je trouvais ça moins élégant donc je me suis dit, des gifs animés sous windows xp avec un delay variables et/ou des combinaisons de frames, ça fait suffisament de contraintes pour que je puisse l”ignorer.

          • Thomas Levesque says:

            Bon, finalement je suis un peu revenu en arrière… J”ai intégré tes suggestions pour l”amélioration des perfs, et j”utilise les métadonnées WPF quand elles sont dispo. Le décodage “manuel” n”est fait que si c”est nécessaire (sous XP par exemple)

  10. Kris says:

    Bonjour thomas,

    J”ai essayé le code et il fonctionne bien. Mais j”ai quand même un problème. Lorsque le gif est stocké en local dans les ressources de mon application tout va bien. Seulement, la plupart de mes gifs sont stockés sur un filer que je vais lire. J”utilse donc des adresses absolues du genre “http://mondomaine/monrepertoire/monimage.gif” et là j”ai cette erreur :

    «Specified argument was out of the range of valid values. Parameter name: index”.»

    Néanmoins si mon image n”est pas en gif mais en jpg ou png, elle se charge bien et je n”ai pas cette exception.
    Aurais tu une idée sur la cause de cette erreur?

    Merci

    • Thomas Levesque says:

      Bonjour Kris,

      La version du code sur ce blog est assez ancienne, il y a eu beaucoup de corrections et améliorations depuis. As-tu essayé avec la version actuelle sur Codeplex ou sur sur Nuget ? Il me semble avoir corrigé un problème lié aux URL HTTP il y a quelques mois.

      • Kris says:

        J”ai bien utilisé la version de Codeplex.
        J”ai intégré la dll à mon projet(avant je testais dans un POC) et là ça fonctionne.
        Je vais faire des tests plus poussés.
        Merci grâce à toi j”ai économisé des jours de développement.

    • Thomas Levesque says:

      Re bonjour,

      J”ai vérifié la version actuelle, il y avait quand même un problème avec les URL HTTP… j”ai publié une nouvelle version qui corrige le problème.

      • Kris says:

        Bonjour,

        Merci pour cette réactivité.
        J4ai récupéré ta nouvelle version et ça fonctionne toujours bien.
        C”était quoi le problème avec les URL http en fait stp?

        • Thomas Levesque says:

          Au moment où j”essayais d”initialiser l”animation, l”image était encore en cours de téléchargement, et on ne pouvait pas encore accéder aux infos des frames, donc ça affichait juste la première image.

Leave a comment

css.php