取消

实现一个 WPF 版本的 ConnectedAnimation

Windows 10 的创造者更新为开发者们带来了 Connected Animation 连接动画,这也是 Fluent Design System 的一部分。它的视觉引导性很强,用户能够在它的帮助下迅速定位操作的对象。

不过,这是 UWP,而且还是 Windows 10 Creator’s Update 中才带来的特性,WPF 当然没有。于是,我自己写了一个“简易版本”。


Connected Animation
▲ Connected Animation 连接动画

模拟 UWP 中的 API

UWP 中的连接动画能跑起来的最简单代码包含下面两个部分。

准备动画 PrepareToAnimate()

1
ConnectedAnimationService.GetForCurrentView().PrepareToAnimate(/*string */key, /*UIElement */source);

开始动画 TryStart

1
2
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 的实例,我使用字典存储这些实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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 中没有这样的特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 的作用是显示一个 ConnectedVisualConnectedVisual 包含一个源和一个目标,根据 Progress(进度)属性决定应该分别将源和目标显示到哪个位置,其不透明度分别是多少。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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 属性,来实现连接动画。

完整的包含内部类的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
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);
                    }
                }
            }
        }
    }
}

调用

我在一个按钮的点击事件里面尝试调用上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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++;
}

连接动画试验
▲ 上面的代码做的连接动画

目前的局限性以及改进计划

然而稍微试试不难发现,这段代码很难将控件本身隐藏起来(设置 VisibilityCollapsed),也就是说如果源控件和目标控件一直显示,那么动画期间就不允许隐藏(不同时显示就没有这个问题)。这样也就出不来“连接”的感觉,而是覆盖的感觉。

通过修改调用方的代码,可以规避这个问题。而做法是隐藏控件本身,但对控件内部的可视元素子级进行动画。这样,动画就仅限继承自 Control 的那些元素(例如 ButtonUserControl 了)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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://blog.walterlv.com/post/connected-animation-of-wpf.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 吕毅 (包含链接: https://blog.walterlv.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 ([email protected])

登录 GitHub 账号进行评论