C# WPF 内置解码器实现 GIF 动图控件

相对于 WinForm PictureBox 控件原生支持动态 GIF,WPF Image 控件却不支持,让人摸不着头脑

常用方法

提到 WPF 播放动图,常见的方法有三种

MediaElement

使用 MediaElement 控件,缺点是依赖 Media Player,且不支持透明

xml 复制代码
<MediaElement Source="animation.gif" LoadedBehavior="Play" Stretch="Uniform"/>

WinForm PictureBox

借助 WindowsFormsIntegration 嵌入 WinForm PictureBox,缺点是不支持透明

xml 复制代码
<WindowsFormsHost>
    <wf:PictureBox x:Name="winFormsPictureBox"/>
</WindowsFormsHost>

WpfAnimatedGif

引用 NuGet 包 WpfAnimatedGif,支持透明

xml 复制代码
<Image gif:ImageBehavior.AnimatedSource="Images/animation.gif"/>

作者还有另一个性能更好、跨平台的 XamlAnimatedGif,用法相同

原生解码方法

WPF 虽然原生 Image 不支持 GIF 动图,但是提供了 GifBitmapDecoder 解码器,可以获取元数据,包括循环信息、逻辑尺寸、所有帧信息等

判断是否循环和循环次数

csharp 复制代码
int loop = 1;
bool isAnimated = true;
var decoder = new GifBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.Default);
var data = decoder.Metadata;
if (data.GetQuery("/appext/Application") is byte[] array1)
{
    string appName = Encoding.ASCII.GetString(array1);
    if ((appName == "NETSCAPE2.0" || appName == "ANIMEXTS1.0")
        && data.GetQuery("/appext/Data") is byte[] array2)
    {
        loop = array2[2] | array2[3] << 8;// 获取循环次数, 0 表示无限循环
        isAnimated = array2[1] == 1;
    }
}

获取画布逻辑尺寸

csharp 复制代码
var width = Convert.ToUInt16(data.GetQuery("/logscrdesc/Width"));
var height = Convert.ToUInt16(data.GetQuery("/logscrdesc/Height"));

获取每一帧信息

csharp 复制代码
/// <summary>当前帧播放完成后的处理方法</summary>
enum DisposalMethod
{
    /// <summary>被全尺寸不透明的下一帧覆盖替换</summary>
    None,
    /// <summary>不丢弃, 继续显示下一帧未覆盖的任何像素</summary>
    DoNotDispose,
    /// <summary>重置到背景色</summary>
    RestoreBackground,
    /// <summary>恢复到上一个未释放的帧的状态</summary>
    RestorePrevious,
}

sealed class FrameInfo
{
    public Image Frame { get; }
    public int DelayTime { get; }
    public DisposalMethod DisposalMethod { get; }

    public FrameInfo(BitmapFrame frame)
    {
        Frame = new Image { Source = frame };
        var data = (BitmapMetadata)frame.Metadata;
        DelayTime = Convert.ToUInt16(data.GetQuery("/grctlext/Delay"));
        DisposalMethod = (DisposalMethod)Convert.ToByte(data.GetQuery("/grctlext/Disposal"));
        ushort left = Convert.ToUInt16(data.GetQuery("/imgdesc/Left"));
        ushort top = Convert.ToUInt16(data.GetQuery("/imgdesc/Top"));
        ushort width = Convert.ToUInt16(data.GetQuery("/imgdesc/Width"));
        ushort height = Convert.ToUInt16(data.GetQuery("/imgdesc/Height"));
        Canvas.SetLeft(Frame, left);
        Canvas.SetTop(Frame, top);
        Canvas.SetRight(Frame, left + width);
        Canvas.SetBottom(Frame, top + height);
    }
}

完整代码

将所有帧画面按其大小位置和顺序放置在 Canvas 中,结合所有帧的播放处理方法和持续时间,使用关键帧动画,即可实现无需依赖第三方的自定义控件,且性能和 XamlAnimatedGif 相差无几

csharp 复制代码
using System;
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;

public sealed class GifImage : ContentControl
{
    /// <summary>当前帧播放完成后的处理方法</summary>
    enum DisposalMethod
    {
        /// <summary>被全尺寸不透明的下一帧覆盖替换</summary>
        None,
        /// <summary>不丢弃, 继续显示下一帧未覆盖的任何像素</summary>
        DoNotDispose,
        /// <summary>重置到背景色</summary>
        RestoreBackground,
        /// <summary>恢复到上一个未释放的帧的状态</summary>
        RestorePrevious,
    }

    sealed class FrameInfo
    {
        public Image Frame { get; }
        public int DelayTime { get; }
        public DisposalMethod DisposalMethod { get; }

        public FrameInfo(BitmapFrame frame)
        {
            Frame = new Image { Source = frame };
            var data = (BitmapMetadata)frame.Metadata;
            DelayTime = Convert.ToUInt16(data.GetQuery("/grctlext/Delay"));
            DisposalMethod = (DisposalMethod)Convert.ToByte(data.GetQuery("/grctlext/Disposal"));
            ushort left = Convert.ToUInt16(data.GetQuery("/imgdesc/Left"));
            ushort top = Convert.ToUInt16(data.GetQuery("/imgdesc/Top"));
            ushort width = Convert.ToUInt16(data.GetQuery("/imgdesc/Width"));
            ushort height = Convert.ToUInt16(data.GetQuery("/imgdesc/Height"));
            Canvas.SetLeft(Frame, left);
            Canvas.SetTop(Frame, top);
            Canvas.SetRight(Frame, left + width);
            Canvas.SetBottom(Frame, top + height);
        }
    }

    public static readonly DependencyProperty UriSourceProperty =
        DependencyProperty.Register(nameof(UriSource), typeof(Uri), typeof(GifImage), new PropertyMetadata(null, OnSourceChanged));

    public static readonly DependencyProperty StreamSourceProperty =
        DependencyProperty.Register(nameof(StreamSource), typeof(Stream), typeof(GifImage), new PropertyMetadata(null, OnSourceChanged));

    public static readonly DependencyProperty FrameIndexProperty =
        DependencyProperty.Register(nameof(FrameIndex), typeof(int), typeof(GifImage), new PropertyMetadata(0, OnFrameIndexChanged));

    public static readonly DependencyProperty StretchProperty =
        DependencyProperty.Register(nameof(Stretch), typeof(Stretch), typeof(GifImage), new PropertyMetadata(Stretch.None, OnStrechChanged));

    public static readonly DependencyProperty StretchDirectionProperty =
        DependencyProperty.Register(nameof(StretchDirection), typeof(StretchDirection), typeof(GifImage), new PropertyMetadata(StretchDirection.Both, OnStrechDirectionChanged));

    public static readonly DependencyProperty IsLoadingProperty =
        DependencyProperty.Register(nameof(IsLoading), typeof(bool), typeof(GifImage), new PropertyMetadata(false));

    public Uri UriSource
    {
        get => (Uri)GetValue(UriSourceProperty);
        set => SetValue(UriSourceProperty, value);
    }

    public Stream StreamSource
    {
        get => (Stream)GetValue(StreamSourceProperty);
        set => SetValue(StreamSourceProperty, value);
    }

    public int FrameIndex
    {
        get => (int)GetValue(FrameIndexProperty);
        private set => SetValue(FrameIndexProperty, value);
    }

    public Stretch Stretch
    {
        get => (Stretch)GetValue(StretchProperty);
        set => SetValue(StretchProperty, value);
    }

    public StretchDirection StretchDirection
    {
        get => (StretchDirection)GetValue(StretchDirectionProperty);
        set => SetValue(StretchDirectionProperty, value);
    }

    public bool IsLoading
    {
        get => (bool)GetValue(IsLoadingProperty);
        set => SetValue(IsLoadingProperty, value);
    }

    private static void OnSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((GifImage)d)?.OnSourceChanged();
    }

    private static void OnFrameIndexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((GifImage)d)?.OnFrameIndexChanged();
    }

    private static void OnStrechChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is GifImage image && image.Content is Viewbox viewbox)
        {
            viewbox.Stretch = image.Stretch;
        }
    }

    private static void OnStrechDirectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is GifImage image && image.Content is Viewbox viewbox)
        {
            viewbox.StretchDirection = image.StretchDirection;
        }
    }

    Stream stream;
    Canvas canvas;
    FrameInfo[] frameInfos;
    Int32AnimationUsingKeyFrames animation;

    public GifImage()
    {
        IsVisibleChanged += OnIsVisibleChanged;
        Unloaded += OnUnloaded;
    }

    private void OnUnloaded(object sender, RoutedEventArgs e)
    {
        Release();
    }

    private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        if (IsVisible)
        {
            StartAnimation();
        }
        else
        {
            StopAnimation();
        }
    }

    private void StartAnimation()
    {
        BeginAnimation(FrameIndexProperty, animation);
    }

    private void StopAnimation()
    {
        BeginAnimation(FrameIndexProperty, null);
    }

    private void Release()
    {
        StopAnimation();
        canvas?.Children.Clear();
        stream?.Dispose();
        animation = null;
        frameInfos = null;
    }

    private async void OnSourceChanged()
    {
        Release();
        IsLoading = true;
        FrameIndex = 0;
        if (UriSource != null)
        {
            stream = await ResourceHelper.GetStream(UriSource);
        }
        else
        {
            stream = StreamSource;
        }
        if (stream != null)
        {
            int loop = 1;
            bool isAnimated = true;
            var decoder = new GifBitmapDecoder(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.Default);
            var data = decoder.Metadata;
            if (data.GetQuery("/appext/Application") is byte[] array1)
            {
                string appName = Encoding.ASCII.GetString(array1);
                if ((appName == "NETSCAPE2.0" || appName == "ANIMEXTS1.0")
                    && data.GetQuery("/appext/Data") is byte[] array2)
                {
                    loop = array2[2] | array2[3] << 8;// 获取循环次数, 0表示无限循环
                    isAnimated = array2[1] == 1;
                }
            }
            if (!(Content is Viewbox viewbox))
            {
                Content = viewbox = new Viewbox
                {
                    Stretch = Stretch,
                    StretchDirection = StretchDirection,
                };
            }
            if (canvas == null || canvas.Parent != Content)
            {
                canvas = new Canvas();
                viewbox.Child = canvas;
            }
            canvas.Width = Convert.ToUInt16(data.GetQuery("/logscrdesc/Width"));
            canvas.Height = Convert.ToUInt16(data.GetQuery("/logscrdesc/Height"));
            int count = decoder.Frames.Count;
            frameInfos = new FrameInfo[count];
            for (int i = 0; i < count; i++)
            {
                var info = new FrameInfo(decoder.Frames[i]);
                Image frame = info.Frame;
                frameInfos[i] = info;
                canvas.Children.Add(frame);
                Panel.SetZIndex(frame, i);
                canvas.Width = Math.Max(canvas.Width, Canvas.GetRight(frame));
                canvas.Height = Math.Max(canvas.Height, Canvas.GetBottom(frame));
            }
            OnFrameIndexChanged();
            if (isAnimated)
            {
                var keyFrames = new Int32KeyFrameCollection();
                var last = TimeSpan.Zero;
                for (int i = 0; i < frameInfos.Length; i++)
                {
                    last += TimeSpan.FromMilliseconds(frameInfos[i].DelayTime * 10);
                    keyFrames.Add(new DiscreteInt32KeyFrame(i, last));
                }
                animation = new Int32AnimationUsingKeyFrames
                {
                    KeyFrames = keyFrames,
                    RepeatBehavior = loop == 0 ? RepeatBehavior.Forever : new RepeatBehavior(loop)
                };
                StartAnimation();
            }
        }
        IsLoading = false;
    }

    private void OnFrameIndexChanged()
    {
        if (frameInfos != null)
        {
            int index = FrameIndex;
            frameInfos[index].Frame.Visibility = Visibility.Visible;
            if (index > 0)
            {
                var previousInfo = frameInfos[index - 1];
                switch (previousInfo.DisposalMethod)
                {
                    case DisposalMethod.RestoreBackground:
                        // 隐藏之前的所有帧
                        for (int i = 0; i < index - 1; i++)
                        {
                            frameInfos[i].Frame.Visibility = Visibility.Hidden;
                        }
                        break;
                    case DisposalMethod.RestorePrevious:
                        // 隐藏上一帧
                        previousInfo.Frame.Visibility = Visibility.Hidden;
                        break;
                }
            }
            else
            {
                // 重新循环, 只显示第一帧
                for (int i = 1; i < frameInfos.Length; i++)
                {
                    frameInfos[i].Frame.Visibility = Visibility.Hidden;
                }
            }
        }
    }
}

使用到的从 URL 获取图像流的方法

csharp 复制代码
using System;
using System.IO;
using System.IO.Packaging;
using System.Net;
using System.Threading.Tasks;
using System.Windows;

public static class ResourceHelper
{
    public static Task<Stream> GetStream(Uri uri)
    {
        if (!uri.IsAbsoluteUri)
        {
            throw new ArgumentException("uri must be absolute");
        }
        if (uri.Scheme == Uri.UriSchemeHttps
            || uri.Scheme == Uri.UriSchemeHttp
            || uri.Scheme == Uri.UriSchemeFtp)
        {
            return Task.Run<Stream>(() =>
            {
                using (var client = new WebClient())
                {
                    byte[] data = client.DownloadData(uri);
                    return new MemoryStream(data);
                }
            });
        }
        else if (uri.Scheme == PackUriHelper.UriSchemePack)
        {
            var info = uri.Authority == "siteoforigin:,,,"
                ? Application.GetRemoteStream(uri)
                : Application.GetResourceStream(uri);
            if (info != null)
            {
                return Task.FromResult(info.Stream);
            }
        }
        else if (uri.Scheme == Uri.UriSchemeFile)
        {
            return Task.FromResult<Stream>(File.OpenRead(uri.LocalPath));
        }
        throw new FileNotFoundException(uri.OriginalString);
    }
}

ImageAnimator

WinForm 中播放 GIF 用到了 ImageAnimator,利用它也可以在 WPF 中实现 GIF 动图控件,但其是基于 GDI 的方法,更推荐性能更好、支持硬解的解码器方法

csharp 复制代码
// 将多帧图像显示为动画,并触发事件
ImageAnimator.Animate(Image, EventHandler)

// 暂停动画
ImageAnimator.StopAnimate(Image, EventHandler)

// 判断图像是否支持动画
ImageAnimator.CanAnimate(Image)

// 在图像中前进帧,下次渲染图像时绘制新帧
ImageAnimator.UpdateFrames(Image)

透明 GIF

GIF 本身只有 256 色,没有 Alpha 通道,但其仍支持透明,是通过其特殊的自定义颜色表调色盘实现的

上图是一张单帧透明 GIF,使用 Windows 自带画图打开,会错误显示为橙色背景

放入 WinForm PictureBox 中,Win7 和较旧的 Win10 也会错误显示为橙色背景

但最新的 Win11 和 Win10 上会显示为透明背景,猜测是近期 Win11 在截图工具中推出了录制 GIF 功能时顺手更新了 .NET System.Drawing GIF 解析方法,Win10 也收到了这次补丁更新

不过使用 WPF 解码器方法能过获得正确的背景

相关资料

Table of Contents

Native Image Format Metadata Queries - Win32 apps

WICGifGraphicControlExtensionProperties (wincodec.h) - Win32 apps | Microsoft Learn

WICGifImageDescriptorProperties (wincodec.h) - Win32 apps | Microsoft Learn

[WPF疑难]在WPF中显示动态GIF - 周银辉 - 博客园

wpf GifBitmapDecoder 解析 gif 格式

浓缩的才是精华:浅析GIF格式图片的存储和压缩 - 腾讯云开发者 - 博客园

相关推荐
huluang2 分钟前
Word XML 批注范围克隆处理器
开发语言·c#
钱彬 (Qian Bin)16 分钟前
AI质检数据准备利器:基于Qt/QML 5.14的图像批量裁剪工具开发实战
qt·自定义·图像·qml·qt quick·裁剪工具
@Jackasher3 小时前
Redis如何实现一个分布式锁?
redis·分布式·wpf
唐青枫4 小时前
别再用 Thread 了!掌握 C#.NET Task 异步编程的正确打开方式
c#·.net
月巴月巴白勺合鸟月半5 小时前
一个C#的段子
开发语言·c#
Kookoos13 小时前
ABP VNext + Akka.NET:高并发处理与分布式计算
.net·abp vnext·akka.net·actor model·背压
筱璦16 小时前
最新完整内、外期货量化交易系统C#源码可售
c#
xingkongvv1216 小时前
Linq Union和Concat
c#·linq
钢铁男儿17 小时前
C# 异步编程:提升程序性能与用户体验的利器
c#·php·ux