WPF 实现类似 ChatGPT 的逐字打印效果

103 阅读4分钟

前言

ChatGPT类的应用风靡一时,其最引人注目的特点之一就是在回答用户问题时,像真人打字一样逐字输出内容。这种效果不仅增强了交互的真实感,也让用户在等待回复的过程中有了更自然的体验。出于对这一效果的好奇,决定尝试用WPF来模拟实现这一功能。

虽然真实的ChatGPT逐字输出效果涉及复杂的语言生成模型和前后端通信机制,本文并不打算深入探讨这些底层技术,而是聚焦于如何在WPF中通过动画技术实现类似的视觉效果。接下来,我将介绍两种不同的实现方法,并重点展示其中一种通过关键帧动画拼接字符串的具体实现。

效果预览

方法一效果

方法二效果

技术实现

对于逐字输出的效果,构思了两种实现方法:

方法一:关键帧动画拼接字符串

这种方法的核心思想是根据目标字符串的长度,创建多个关键帧(DiscreteStringKeyFrame)。每个关键帧的值依次增加一个字符,从第一个字符开始,直到完整的目标字符串。

通过这种方式,TextBlock的文本内容会逐步"生长",从而实现逐字输出的效果。

具体实现如下:

public classTypingCharAnimationBehavior : Behavior<TextBlock>{    private Storyboard _storyboard;    protected override void OnAttached()    {        base.OnAttached();        this.AssociatedObject.Loaded += AssociatedObject_Loaded;        this.AssociatedObject.Unloaded += AssociatedObject_Unloaded;        BindingOperations.SetBinding(this, TypingCharAnimationBehavior.InternalTextProperty, new Binding("Tag") { Source = this.AssociatedObject });    }    private void AssociatedObject_Unloaded(object sender, RoutedEventArgs e)    {        StopEffect();    }    private void AssociatedObject_Loaded(object sender, RoutedEventArgs e)    {        if (IsEnabled)            BeginEffect(InternalText);    }    protected override void OnDetaching()    {        base.OnDetaching();        this.AssociatedObject.Loaded -= AssociatedObject_Loaded;        this.AssociatedObject.Unloaded -= AssociatedObject_Unloaded;        this.ClearValue(TypingCharAnimationBehavior.InternalTextProperty);        if (_storyboard != null)        {            _storyboard.Remove(this.AssociatedObject);            _storyboard.Children.Clear();        }    }    privatestring InternalText    {        get { return (string)GetValue(InternalTextProperty); }        set { SetValue(InternalTextProperty, value); }    }    privatestaticreadonly DependencyProperty InternalTextProperty =    DependencyProperty.Register("InternalText"typeof(string), typeof(TypingCharAnimationBehavior),    new PropertyMetadata(OnInternalTextChanged));    private static void OnInternalTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)    {        var source = d as TypingCharAnimationBehavior;        if (source._storyboard != null)        {            source._storyboard.Stop(source.AssociatedObject);            source._storyboard.Children.Clear();        }        source.SetEffect(e.NewValue == null ? string.Empty : e.NewValue.ToString());    }    publicbool IsEnabled    {        get { return (bool)GetValue(IsEnabledProperty); }        set { SetValue(IsEnabledProperty, value); }    }    publicstaticreadonly DependencyProperty IsEnabledProperty =        DependencyProperty.Register("IsEnabled"typeof(bool), typeof(TypingCharAnimationBehavior), new PropertyMetadata(true, (d, e) =>        {            bool b = (bool)e.NewValue;            var source = d as TypingCharAnimationBehavior;            source.SetEffect(source.InternalText);        }));    private void SetEffect(string text)    {        if (string.IsNullOrEmpty(text) || this.AssociatedObject.IsLoaded == false)        {            StopEffect();            return;        }        BeginEffect(text);    }    private void StopEffect()    {        if (_storyboard != null)        {            _storyboard.Stop(this.AssociatedObject);        }    }    private void BeginEffect(string text)    {        StopEffect();        int textLength = text.Length;        if (textLength < 1 || IsEnabled == falsereturn;        if (_storyboard == null)            _storyboard = new Storyboard();        double duration = 0.15d;        StringAnimationUsingKeyFrames frames = new StringAnimationUsingKeyFrames();        Storyboard.SetTargetProperty(frames, new PropertyPath(TextBlock.TextProperty));        frames.Duration = TimeSpan.FromSeconds(textLength * duration);        for (int i = 0; i < textLength; i++)        {            frames.KeyFrames.Add(new DiscreteStringKeyFrame()            {                Value = text.Substring(0, i + 1),                KeyTime = TimeSpan.FromSeconds(i * duration),            });        }        _storyboard.Children.Add(frames);        _storyboard.Begin(this.AssociatedObject, true);    }}

由于每一帧都在修改TextBlockText属性,如果直接绑定TextBlockText属性,当数据源发生变化时,无法区分是关键帧动画修改的还是外部数据源变化导致的。因此,这里使用TextBlockTag属性来暂存要显示的字符串内容。调用时只需将需要显示的字符串变量绑定到Tag,并在TextBlock中添加Behavior即可,代码如下:

<TextBlock x:Name="source"           IsEnabled="True"           Tag="{Binding TypingText, ElementName=self}"           TextWrapping="Wrap">    <i:Interaction.Behaviors>        <local:TypingCharAnimationBehavior IsEnabled="True" />    </i:Interaction.Behaviors></TextBlock>

方法二:通过TextEffect控制字体颜色

另一种方法是先将TextBlock的字体颜色设置为透明,然后通过TextEffectPositionStartPositionCount属性控制应用动画效果的子字符串的起始位置和长度,同时使用ColorAnimation设置TextEffectForeground属性由透明变为目标颜色(如黑色)。

这种方法的实现思路与WPF中实现跳动字符效果类似,具体实现不再详述。

两种方案各有优劣

关键帧动画拼接字符串的优点是最大程度还原了逐字输出的过程,缺点是需要额外的属性(如Tag)来辅助,并且在遇到英文单词换行时,可能会出现单词从上一行行尾跳到下一行行首的问题。

通过TextEffect设置字体颜色的方法则相反,不需要额外的属性辅助,且不会出现单词在输入过程中从行尾跳到行首的问题。然而,这种方法实际上是将所有文字预先渲染到界面上,只是通过透明的字体颜色"欺骗"用户的眼睛,逐字改变字体颜色来模拟逐字打印的效果。

总结

通过本次实践,我们成功地在WPF中模拟了ChatGPT类应用的逐字输出效果。两种实现方法各有特点,开发者可以根据具体需求选择合适的方式。

关键帧动画拼接字符串更贴近真实的逐字输入过程,而通过TextEffect控制字体颜色则在性能和稳定性上更具优势。

无论选择哪种方法,WPF的强大动画系统都为我们提供了丰富的工具来实现复杂的视觉效果。希望本文能为有类似需求的开发提供一些启发和帮助。

关键词

WPF、逐字输出、关键帧动画、TextEffect、Behavior、ChatGPT、动画效果、字符串拼接、字体颜色、UI设计

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!

优秀是一种习惯,欢迎大家留言学习!

作者:czwy

出处:cnblogs.com/czwy/p/17617600.html

声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!