前一段时间ChatGPT类的应用十分火爆,这类应用在回答用户的问题时逐字打印输出,像极了真人打字回复消息。出于对这个效果的兴趣,决定用WPF模拟这个效果。
真实的ChatGPT逐字输出效果涉及其语言生成模型原理以及服务端与前端通信机制,本文不做过多阐述,重点是如何用WPF模拟这个效果。
对于这个逐字输出的效果,我想到了两种实现方法:
DiscreteStringKeyFrame
,第一帧的Value
为字符串的第一个字符,紧接着的关键帧都比上一帧的Value
多一个字符,直到最后一帧的Value
是完整的目标字符串。实现效果如下所示:TextBlock
的字体颜色设置为透明,然后通过TextEffect
的PositionStart
和PositionCount
属性控制应用动画效果的子字符串的起始位置以及长度,同时使用ColorAnimation
设置TextEffect
的Foreground
属性由透明变为目标颜色(假定是黑色)。实现效果如下所示:由于方案二的思路与WPF实现跳动的字符效果中的效果实现思路非常类似,具体实现不再详述。接下来我们看一下方案一通过关键帧动画拼接字符串的具体实现。
public class TypingCharAnimationBehavior : 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();
}
}
private string InternalText
{
get { return (string)GetValue(InternalTextProperty); }
set { SetValue(InternalTextProperty, value); }
}
private static readonly 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());
}
public bool IsEnabled
{
get { return (bool)GetValue(IsEnabledProperty); }
set { SetValue(IsEnabledProperty, value); }
}
public static readonly 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 == false) return;
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);
}
}
由于每一帧都在修改TextBlock
的Text
属性的值,如果TypingCharAnimationBehavior
直接绑定TextBlock
的Text
属性,当Text
属性的数据源发生变化时,无法判断是关键帧动画修改的,还是外部数据源变化导致Text
的值被修改。因此这里用TextBlock
的Tag
属性暂存要显示的字符串内容。调用的时候只需要把需要显示的字符串变量绑定到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
设置字体颜色这个方法则相反,不需要额外的属性辅助,并且不会出现单词在输入过程中从行尾跳到下一行行首的问题,开篇中两种实现方法效果图中能看出这一细微差异。但是一开始就把文字都渲染到界面上,只是通过透明的字体颜色骗过用户的眼睛,逐字改变字体颜色模拟逐字打印的效果。