Windows 10 的创造者更新为开发者们带来了 Connected Animation 连接动画,这也是 Fluent Design System 的一部分。它的视觉引导性很强,用户能够在它的帮助下迅速定位操作的对象。
不过,这是 UWP,而且还是 Windows 10 Creator’s Update 中才带来的特性,WPF 当然没有。于是,我自己写了一个“简易版本”。
▲ Connected Animation 连接动画
模拟 UWP 中的 API
UWP 中的连接动画能跑起来的最简单代码包含下面两个部分。
准备动画 PrepareToAnimate()
:
ConnectedAnimationService.GetForCurrentView().PrepareToAnimate(/*string */key, /*UIElement */source);
开始动画 TryStart
:
var animation = ConnectedAnimationService.GetForCurrentView().GetAnimation(/*string */key); animation?.TryStart(/*UIElement */destination);
于是,我们至少需要实现这些 API:
-
ConnectedAnimationService.GetForCurrentView();
-
ConnectedAnimationService.PrepareToAnimate(string key, UIElement source);
-
ConnectedAnimationService.GetAnimation(string key);
-
ConnectedAnimation.TryStart(UIElement destination);
实现这个 API
现在,我们需要写两个类才能实现上面那些方法:
-
ConnectedAnimationService
- 用来管理一个窗口内的所有连接动画 -
ConnectedAnimation
- 用来管理和播放一个指定 Key 的连接动画
ConnectedAnimationService
我选用窗口作为一个 ConnectedAnimationService
的管理单元是因为我可以在一个窗口内实现这样的动画,而跨窗口的动画就非常麻烦了。所以,我试用附加属性为 Window
附加一个 ConnectedAnimationService
属性,用于在任何一个 View
所在的地方获取 ConnectedAnimationService
的实例。
每次 PrepareToAnimate
时我创建一个 ConnectedAnimation
实例来管理此次的连接动画。为了方便此后根据 Key 查找 ConnectedAnimation
的实例,我使用字典存储这些实例。
using System; using System.Collections.Generic; using System.Windows; using System.Windows.Media; using Walterlv.Annotations; namespace Walterlv.Demo.Media.Animation { public class ConnectedAnimationService { private ConnectedAnimationService() { } private readonly Dictionary<string, ConnectedAnimation> _connectingAnimations = new Dictionary<string, ConnectedAnimation>(); public void PrepareToAnimate([NotNull] string key, [NotNull] UIElement source) { if (key == null) { throw new ArgumentNullException(nameof(key)); } if (source == null) { throw new ArgumentNullException(nameof(source)); } if (_connectingAnimations.TryGetValue(key, out var info)) { throw new ArgumentException("指定的 key 已经做好动画准备,不应该重复进行准备。", nameof(key)); } info = new ConnectedAnimation(key, source, OnAnimationCompleted); _connectingAnimations.Add(key, info); } private void OnAnimationCompleted(object sender, EventArgs e) { var key = ((ConnectedAnimation) sender).Key; if (_connectingAnimations.ContainsKey(key)) { _connectingAnimations.Remove(key); } } [CanBeNull] public ConnectedAnimation GetAnimation([NotNull] string key) { if (key == null) { throw new ArgumentNullException(nameof(key)); } if (_connectingAnimations.TryGetValue(key, out var info)) { return info; } return null; } private static readonly DependencyProperty AnimationServiceProperty = DependencyProperty.RegisterAttached("AnimationService", typeof(ConnectedAnimationService), typeof(ConnectedAnimationService), new PropertyMetadata(default(ConnectedAnimationService))); public static ConnectedAnimationService GetForCurrentView(Visual visual) { var window = Window.GetWindow(visual); if (window == null) { throw new ArgumentException("此 Visual 未连接到可见的视觉树中。", nameof(visual)); } var service = (ConnectedAnimationService) window.GetValue(AnimationServiceProperty); if (service == null) { service = new ConnectedAnimationService(); window.SetValue(AnimationServiceProperty, service); } return service; } } }
ConnectedAnimation
这是连接动画的关键实现。
我创建了一个内部类 ConnectedAnimationAdorner
用于在 AdornerLayer
上承载连接动画。 AdornerLayer
是 WPF 中的概念,用于在其他控件上叠加显示一些 UI,UWP 中没有这样的特性。
private class ConnectedAnimationAdorner : Adorner { private ConnectedAnimationAdorner([NotNull] UIElement adornedElement) : base(adornedElement) { Children = new VisualCollection(this); IsHitTestVisible = false; } internal VisualCollection Children { get; } protected override int VisualChildrenCount => Children.Count; protected override Visual GetVisualChild(int index) => Children[index]; protected override Size ArrangeOverride(Size finalSize) { foreach (var child in Children.OfType<UIElement>()) { child.Arrange(new Rect(child.DesiredSize)); } return finalSize; } internal static ConnectedAnimationAdorner FindFrom([NotNull] Visual visual) { if (Window.GetWindow(visual)?.Content is UIElement root) { var layer = AdornerLayer.GetAdornerLayer(root); if (layer != null) { var adorner = layer.GetAdorners(root)?.OfType<ConnectedAnimationAdorner>().FirstOrDefault(); if (adorner == null) { adorner = new ConnectedAnimationAdorner(root); layer.Add(adorner); } return adorner; } } throw new InvalidOperationException("指定的 Visual 尚未连接到可见的视觉树中,找不到用于承载动画的容器。"); } internal static void ClearFor([NotNull] Visual visual) { if (Window.GetWindow(visual)?.Content is UIElement root) { var layer = AdornerLayer.GetAdornerLayer(root); var adorner = layer?.GetAdorners(root)?.OfType<ConnectedAnimationAdorner>().FirstOrDefault(); if (adorner != null) { layer.Remove(adorner); } } } }
而 ConnectedAnimationAdorner
的作用是显示一个 ConnectedVisual
。 ConnectedVisual
包含一个源和一个目标,根据 Progress
(进度)属性决定应该分别将源和目标显示到哪个位置,其不透明度分别是多少。
private class ConnectedVisual : DrawingVisual { public static readonly DependencyProperty ProgressProperty = DependencyProperty.Register( "Progress", typeof(double), typeof(ConnectedVisual), new PropertyMetadata(0.0, OnProgressChanged), ValidateProgress); public double Progress { get => (double) GetValue(ProgressProperty); set => SetValue(ProgressProperty, value); } private static bool ValidateProgress(object value) => value is double progress && progress >= 0 && progress <= 1; private static void OnProgressChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ((ConnectedVisual) d).Render((double) e.NewValue); } public ConnectedVisual([NotNull] Visual source, [NotNull] Visual destination) { _source = source ?? throw new ArgumentNullException(nameof(source)); _destination = destination ?? throw new ArgumentNullException(nameof(destination)); _sourceBrush = new VisualBrush(source) {Stretch = Stretch.Fill}; _destinationBrush = new VisualBrush(destination) {Stretch = Stretch.Fill}; } private readonly Visual _source; private readonly Visual _destination; private readonly Brush _sourceBrush; private readonly Brush _destinationBrush; private Rect _sourceBounds; private Rect _destinationBounds; protected override void OnVisualParentChanged(DependencyObject oldParent) { if (VisualTreeHelper.GetParent(this) == null) { return; } var sourceBounds = VisualTreeHelper.GetContentBounds(_source); if (sourceBounds.IsEmpty) { sourceBounds = VisualTreeHelper.GetDescendantBounds(_source); } _sourceBounds = new Rect( _source.PointToScreen(sourceBounds.TopLeft), _source.PointToScreen(sourceBounds.BottomRight)); _sourceBounds = new Rect( PointFromScreen(_sourceBounds.TopLeft), PointFromScreen(_sourceBounds.BottomRight)); var destinationBounds = VisualTreeHelper.GetContentBounds(_destination); if (destinationBounds.IsEmpty) { destinationBounds = VisualTreeHelper.GetDescendantBounds(_destination); } _destinationBounds = new Rect( _destination.PointToScreen(destinationBounds.TopLeft), _destination.PointToScreen(destinationBounds.BottomRight)); _destinationBounds = new Rect( PointFromScreen(_destinationBounds.TopLeft), PointFromScreen(_destinationBounds.BottomRight)); } private void Render(double progress) { var bounds = new Rect( (_destinationBounds.Left - _sourceBounds.Left) * progress + _sourceBounds.Left, (_destinationBounds.Top - _sourceBounds.Top) * progress + _sourceBounds.Top, (_destinationBounds.Width - _sourceBounds.Width) * progress + _sourceBounds.Width, (_destinationBounds.Height - _sourceBounds.Height) * progress + _sourceBounds.Height); using (var dc = RenderOpen()) { dc.DrawRectangle(_sourceBrush, null, bounds); dc.PushOpacity(progress); dc.DrawRectangle(_destinationBrush, null, bounds); dc.Pop(); } } }
最后,用一个 DoubleAnimation
控制 Progress
属性,来实现连接动画。
完整的包含内部类的代码如下:
using System; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Documents; using System.Windows.Media; using System.Windows.Media.Animation; using Walterlv.Annotations; namespace Walterlv.Demo.Media.Animation { public class ConnectedAnimation { internal ConnectedAnimation([NotNull] string key, [NotNull] UIElement source, [NotNull] EventHandler completed) { Key = key ?? throw new ArgumentNullException(nameof(key)); _source = source ?? throw new ArgumentNullException(nameof(source)); _reportCompleted = completed ?? throw new ArgumentNullException(nameof(completed)); } public string Key { get; } private readonly UIElement _source; private readonly EventHandler _reportCompleted; public bool TryStart([NotNull] UIElement destination) { return TryStart(destination, Enumerable.Empty<UIElement>()); } public bool TryStart([NotNull] UIElement destination, [NotNull] IEnumerable<UIElement> coordinatedElements) { if (destination == null) { throw new ArgumentNullException(nameof(destination)); } if (coordinatedElements == null) { throw new ArgumentNullException(nameof(coordinatedElements)); } if (Equals(_source, destination)) { return false; } // 正在播动画?动画播完废弃了?false // 准备播放连接动画。 var adorner = ConnectedAnimationAdorner.FindFrom(destination); var connectionHost = new ConnectedVisual(_source, destination); adorner.Children.Add(connectionHost); var storyboard = new Storyboard(); var animation = new DoubleAnimation(0.0, 1.0, new Duration(TimeSpan.FromSeconds(10.6))) { EasingFunction = new CubicEase {EasingMode = EasingMode.EaseInOut}, }; Storyboard.SetTarget(animation, connectionHost); Storyboard.SetTargetProperty(animation, new PropertyPath(ConnectedVisual.ProgressProperty.Name)); storyboard.Children.Add(animation); storyboard.Completed += (sender, args) => { _reportCompleted(this, EventArgs.Empty); //destination.ClearValue(UIElement.VisibilityProperty); adorner.Children.Remove(connectionHost); }; //destination.Visibility = Visibility.Hidden; storyboard.Begin(); return true; } private class ConnectedVisual : DrawingVisual { public static readonly DependencyProperty ProgressProperty = DependencyProperty.Register( "Progress", typeof(double), typeof(ConnectedVisual), new PropertyMetadata(0.0, OnProgressChanged), ValidateProgress); public double Progress { get => (double) GetValue(ProgressProperty); set => SetValue(ProgressProperty, value); } private static bool ValidateProgress(object value) => value is double progress && progress >= 0 && progress <= 1; private static void OnProgressChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ((ConnectedVisual) d).Render((double) e.NewValue); } public ConnectedVisual([NotNull] Visual source, [NotNull] Visual destination) { _source = source ?? throw new ArgumentNullException(nameof(source)); _destination = destination ?? throw new ArgumentNullException(nameof(destination)); _sourceBrush = new VisualBrush(source) {Stretch = Stretch.Fill}; _destinationBrush = new VisualBrush(destination) {Stretch = Stretch.Fill}; } private readonly Visual _source; private readonly Visual _destination; private readonly Brush _sourceBrush; private readonly Brush _destinationBrush; private Rect _sourceBounds; private Rect _destinationBounds; protected override void OnVisualParentChanged(DependencyObject oldParent) { if (VisualTreeHelper.GetParent(this) == null) { return; } var sourceBounds = VisualTreeHelper.GetContentBounds(_source); if (sourceBounds.IsEmpty) { sourceBounds = VisualTreeHelper.GetDescendantBounds(_source); } _sourceBounds = new Rect( _source.PointToScreen(sourceBounds.TopLeft), _source.PointToScreen(sourceBounds.BottomRight)); _sourceBounds = new Rect( PointFromScreen(_sourceBounds.TopLeft), PointFromScreen(_sourceBounds.BottomRight)); var destinationBounds = VisualTreeHelper.GetContentBounds(_destination); if (destinationBounds.IsEmpty) { destinationBounds = VisualTreeHelper.GetDescendantBounds(_destination); } _destinationBounds = new Rect( _destination.PointToScreen(destinationBounds.TopLeft), _destination.PointToScreen(destinationBounds.BottomRight)); _destinationBounds = new Rect( PointFromScreen(_destinationBounds.TopLeft), PointFromScreen(_destinationBounds.BottomRight)); } private void Render(double progress) { var bounds = new Rect( (_destinationBounds.Left - _sourceBounds.Left) * progress + _sourceBounds.Left, (_destinationBounds.Top - _sourceBounds.Top) * progress + _sourceBounds.Top, (_destinationBounds.Width - _sourceBounds.Width) * progress + _sourceBounds.Width, (_destinationBounds.Height - _sourceBounds.Height) * progress + _sourceBounds.Height); using (var dc = RenderOpen()) { dc.DrawRectangle(_sourceBrush, null, bounds); dc.PushOpacity(progress); dc.DrawRectangle(_destinationBrush, null, bounds); dc.Pop(); } } } private class ConnectedAnimationAdorner : Adorner { private ConnectedAnimationAdorner([NotNull] UIElement adornedElement) : base(adornedElement) { Children = new VisualCollection(this); IsHitTestVisible = false; } internal VisualCollection Children { get; } protected override int VisualChildrenCount => Children.Count; protected override Visual GetVisualChild(int index) => Children[index]; protected override Size ArrangeOverride(Size finalSize) { foreach (var child in Children.OfType<UIElement>()) { child.Arrange(new Rect(child.DesiredSize)); } return finalSize; } internal static ConnectedAnimationAdorner FindFrom([NotNull] Visual visual) { if (Window.GetWindow(visual)?.Content is UIElement root) { var layer = AdornerLayer.GetAdornerLayer(root); if (layer != null) { var adorner = layer.GetAdorners(root)?.OfType<ConnectedAnimationAdorner>().FirstOrDefault(); if (adorner == null) { adorner = new ConnectedAnimationAdorner(root); layer.Add(adorner); } return adorner; } } throw new InvalidOperationException("指定的 Visual 尚未连接到可见的视觉树中,找不到用于承载动画的容器。"); } internal static void ClearFor([NotNull] Visual visual) { if (Window.GetWindow(visual)?.Content is UIElement root) { var layer = AdornerLayer.GetAdornerLayer(root); var adorner = layer?.GetAdorners(root)?.OfType<ConnectedAnimationAdorner>().FirstOrDefault(); if (adorner != null) { layer.Remove(adorner); } } } } } }
调用
我在一个按钮的点击事件里面尝试调用上面的代码:
private int index; private void AnimationButton_Click(object sender, RoutedEventArgs e) { BeginConnectedAnimation((UIElement)sender, ConnectionDestination); } private async void BeginConnectedAnimation(UIElement source, UIElement destination) { var service = ConnectedAnimationService.GetForCurrentView(this); service.PrepareToAnimate($"Test{index}", source); // 这里特意写在了同一个方法中,以示效果。事实上,只要是同一个窗口中的两个对象都可以实现。 var animation = service.GetAnimation($"Test{index}"); animation?.TryStart(destination); // 每次点击都使用不同的 Key。 index++; }
▲ 上面的代码做的连接动画
目前的局限性以及改进计划
然而稍微试试不难发现,这段代码很难将控件本身隐藏起来(设置 Visibility
为 Collapsed
),也就是说如果源控件和目标控件一直显示,那么动画期间就不允许隐藏(不同时显示就没有这个问题)。这样也就出不来“连接”的感觉,而是覆盖的感觉。
通过修改调用方的代码,可以规避这个问题。而做法是隐藏控件本身,但对控件内部的可视元素子级进行动画。这样,动画就仅限继承自 Control
的那些元素(例如 Button
, UserControl
了)。
private async void BeginConnectedAnimation(UIElement source, UIElement destination) { source.Visibility = Visibility.Hidden; ConnectionDestination.Visibility = Visibility.Hidden; var animatingSource = (UIElement) VisualTreeHelper.GetChild(source, 0); var animatingDestination = (UIElement) VisualTreeHelper.GetChild(destination, 0); var service = ConnectedAnimationService.GetForCurrentView(this); service.PrepareToAnimate($"Test{index}", animatingSource); var animation = service.GetAnimation($"Test{index}"); animation?.TryStart(animatingDestination); index++; await Task.Delay(600); source.ClearValue(VisibilityProperty); ConnectionDestination.ClearValue(VisibilityProperty); }
▲ 修改后的代码做的连接动画
现在,我正试图通过截图和像素着色器(Shader Effect)来实现更加通用的 ConnectedAnimation
,正在努力编写中……
参考资料
本文会经常更新,请阅读原文: https://walterlv.github.io/post/connected-animation-of-wpf.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://walterlv.github.io ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 。
注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。