Skip to content

Commit e6c7c4d

Browse files
authored
Fix: Animation behavior TapGestureRecognizer (CommunityToolkit#2567)
1 parent fea67c2 commit e6c7c4d

File tree

3 files changed

+150
-54
lines changed

3 files changed

+150
-54
lines changed
Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
<?xml version="1.0" encoding="utf-8" ?>
2-
<pages:BasePage
2+
<pages:BasePage
3+
x:Class="CommunityToolkit.Maui.Sample.Pages.Behaviors.AnimationBehaviorPage"
34
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
45
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
6+
xmlns:behaviorPages="clr-namespace:CommunityToolkit.Maui.Sample.Pages.Behaviors"
57
xmlns:mct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
68
xmlns:pages="clr-namespace:CommunityToolkit.Maui.Sample.Pages"
79
xmlns:system="clr-namespace:System;assembly=netstandard"
810
xmlns:vm="clr-namespace:CommunityToolkit.Maui.Sample.ViewModels.Behaviors"
9-
x:Class="CommunityToolkit.Maui.Sample.Pages.Behaviors.AnimationBehaviorPage"
10-
xmlns:behaviorPages="clr-namespace:CommunityToolkit.Maui.Sample.Pages.Behaviors"
11-
x:TypeArguments="vm:AnimationBehaviorViewModel"
12-
x:DataType="vm:AnimationBehaviorViewModel">
11+
x:DataType="vm:AnimationBehaviorViewModel"
12+
x:TypeArguments="vm:AnimationBehaviorViewModel">
1313

14-
<VerticalStackLayout Spacing="15" Padding="12">
14+
<VerticalStackLayout Padding="12" Spacing="15">
1515
<Label>
1616
<Label.FormattedText>
1717
<FormattedString>
@@ -28,69 +28,84 @@
2828
</Label.FormattedText>
2929
</Label>
3030

31-
<Button Text="Click this Button" Margin="16,0">
31+
<Button Margin="16,0" Text="Click this Button">
3232
<Button.Behaviors>
3333
<mct:AnimationBehavior EventName="Clicked">
3434
<mct:AnimationBehavior.AnimationType>
35-
<behaviorPages:SampleScaleAnimation
36-
Easing="{x:Static Easing.Linear}"
37-
Length="100"/>
35+
<behaviorPages:SampleScaleAnimation Easing="{x:Static Easing.Linear}" Length="100" />
3836
</mct:AnimationBehavior.AnimationType>
3937
</mct:AnimationBehavior>
4038
</Button.Behaviors>
4139
</Button>
4240

43-
<Entry Placeholder="Animate on Focused and Unfocused" Margin="16,0">
41+
<Entry Margin="16,0" Placeholder="Animate on Focused and Unfocused">
4442
<Entry.Behaviors>
4543
<mct:AnimationBehavior EventName="Focused">
4644
<mct:AnimationBehavior.AnimationType>
47-
<behaviorPages:SampleScaleToAnimation
45+
<behaviorPages:SampleScaleToAnimation
4846
Easing="{x:Static Easing.Linear}"
4947
Length="100"
50-
Scale="1.05"/>
48+
Scale="1.05" />
5149
</mct:AnimationBehavior.AnimationType>
5250
</mct:AnimationBehavior>
5351

5452
<mct:AnimationBehavior EventName="Unfocused">
5553
<mct:AnimationBehavior.AnimationType>
56-
<behaviorPages:SampleScaleToAnimation
54+
<behaviorPages:SampleScaleToAnimation
5755
Easing="{x:Static Easing.Linear}"
5856
Length="100"
59-
Scale="1"/>
57+
Scale="1" />
58+
</mct:AnimationBehavior.AnimationType>
59+
</mct:AnimationBehavior>
60+
</Entry.Behaviors>
61+
</Entry>
62+
63+
<Entry Margin="16,0" Placeholder="Animate on tap">
64+
<Entry.Behaviors>
65+
<mct:AnimationBehavior AnimateOnTap="{Binding Source={x:Reference AnimateOnTapToggle}, Path=IsToggled, x:DataType=Switch}">
66+
<mct:AnimationBehavior.AnimationType>
67+
<behaviorPages:SampleScaleAnimation Easing="{x:Static Easing.Linear}" Length="100" />
6068
</mct:AnimationBehavior.AnimationType>
6169
</mct:AnimationBehavior>
6270
</Entry.Behaviors>
6371
</Entry>
72+
<HorizontalStackLayout Margin="16,0" Spacing="10">
73+
<Label Text="Toggle animate on tap: " VerticalTextAlignment="Center" />
74+
<Switch x:Name="AnimateOnTapToggle" />
75+
</HorizontalStackLayout>
76+
6477

65-
<!-- Shows how the AnimationBehavior will play nicely with already attached TapGestureRecognizers -->
66-
<Label Text="Click this Label" Margin="16,0" HorizontalOptions="Center">
78+
<!-- Shows how the AnimationBehavior will play nicely with already attached TapGestureRecognizers -->
79+
<Label
80+
Margin="16,0"
81+
HorizontalOptions="Center"
82+
Text="Click this Label">
6783
<Label.GestureRecognizers>
6884
<TapGestureRecognizer Command="{Binding AnimationCommand}" />
6985
</Label.GestureRecognizers>
7086
<Label.Behaviors>
7187
<mct:AnimationBehavior>
7288
<mct:AnimationBehavior.AnimationType>
73-
<behaviorPages:SampleScaleAnimation
74-
Easing="{x:Static Easing.Linear}"
75-
Length="100"/>
89+
<behaviorPages:SampleScaleAnimation Easing="{x:Static Easing.Linear}" Length="100" />
7690
</mct:AnimationBehavior.AnimationType>
7791
</mct:AnimationBehavior>
7892
</Label.Behaviors>
7993
</Label>
8094

81-
<Border BackgroundColor="LightGreen" Margin="16,0" x:Name="AnimatedBorder">
95+
<Border
96+
x:Name="AnimatedBorder"
97+
Margin="16,0"
98+
BackgroundColor="LightGreen">
8299
<Border.Behaviors>
83-
<mct:AnimationBehavior
84-
AnimateCommand="{Binding AnimateFromViewModelCommand}"
85-
BindingContext="{Binding Path=BindingContext, Source={x:Reference AnimatedBorder}, x:DataType=Border}">
100+
<mct:AnimationBehavior AnimateCommand="{Binding AnimateFromViewModelCommand}" BindingContext="{Binding Path=BindingContext, Source={x:Reference AnimatedBorder}, x:DataType=Border}">
86101
<mct:AnimationBehavior.AnimationType>
87102
<mct:FadeAnimation Opacity="0.2" />
88103
</mct:AnimationBehavior.AnimationType>
89104
</mct:AnimationBehavior>
90105
</Border.Behaviors>
91-
<Label Text="Click Inside This Border" HorizontalOptions="Center"/>
106+
<Label HorizontalOptions="Center" Text="Click Inside This Border" />
92107
</Border>
93108

94-
<Button Text="Animate the frame above" Command="{Binding TriggerAnimationCommand}" />
109+
<Button Command="{Binding TriggerAnimationCommand}" Text="Animate the frame above" />
95110
</VerticalStackLayout>
96111
</pages:BasePage>

src/CommunityToolkit.Maui.UnitTests/Behaviors/AnimationBehaviorTests.cs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ namespace CommunityToolkit.Maui.UnitTests.Behaviors;
1010
public class AnimationBehaviorTests() : BaseBehaviorTest<AnimationBehavior, VisualElement>(new AnimationBehavior(), new View())
1111
{
1212
[Fact]
13-
public void TapGestureRecognizerAttachedWhenNoEventSpecified()
13+
public void TapGestureRecognizerAttachedWhenAnimateOnTapSetToTrue()
1414
{
1515
var boxView = new BoxView();
16-
boxView.Behaviors.Add(new AnimationBehavior());
16+
boxView.Behaviors.Add(new AnimationBehavior() { AnimateOnTap = true });
1717
var gestureRecognizers = boxView.GestureRecognizers.ToList();
1818

1919
gestureRecognizers.Should().HaveCount(1).And.AllBeOfType<TapGestureRecognizer>();
@@ -24,14 +24,14 @@ public void TapGestureRecognizerAttachedEvenWithAnotherAlreadyAttached()
2424
{
2525
var boxView = new BoxView();
2626
boxView.GestureRecognizers.Add(new TapGestureRecognizer());
27-
boxView.Behaviors.Add(new AnimationBehavior());
27+
boxView.Behaviors.Add(new AnimationBehavior() { AnimateOnTap = true });
2828
var gestureRecognizers = boxView.GestureRecognizers.ToList();
2929

3030
gestureRecognizers.Should().HaveCount(2).And.AllBeOfType<TapGestureRecognizer>();
3131
}
3232

3333
[Fact]
34-
public void TapGestureRecognizerNotAttachedWhenEventSpecified()
34+
public void TapGestureRecognizerNotAttachedWhenAnimateOnTapSetToFalse()
3535
{
3636
var boxView = new BoxView();
3737
boxView.Behaviors.Add(new AnimationBehavior
@@ -44,10 +44,44 @@ public void TapGestureRecognizerNotAttachedWhenEventSpecified()
4444
}
4545

4646
[Fact]
47-
public void TapGestureRecognizerNotAttachedWhenViewIsInputView()
47+
public void TapGestureRecognizerAddedAndRemovedDynamically()
4848
{
49-
var addBehavior = () => new Entry().Behaviors.Add(new AnimationBehavior());
50-
addBehavior.Should().Throw<InvalidOperationException>();
49+
var behavior = new AnimationBehavior() { AnimateOnTap = false };
50+
51+
var boxView = new BoxView();
52+
boxView.Behaviors.Add(behavior);
53+
var gestureRecognizers = boxView.GestureRecognizers.ToList();
54+
55+
gestureRecognizers.Should().BeEmpty();
56+
57+
behavior.AnimateOnTap = true;
58+
59+
gestureRecognizers = boxView.GestureRecognizers.ToList();
60+
gestureRecognizers.Should().HaveCount(1).And.AllBeOfType<TapGestureRecognizer>();
61+
62+
behavior.AnimateOnTap = false;
63+
64+
gestureRecognizers = boxView.GestureRecognizers.ToList();
65+
gestureRecognizers.Should().BeEmpty();
66+
}
67+
68+
[Fact]
69+
public void CorrectTapGestureRecognizerRemoved()
70+
{
71+
var behavior = new AnimationBehavior() { AnimateOnTap = true };
72+
var boxView = new BoxView();
73+
boxView.GestureRecognizers.Add(new TapGestureRecognizer() { AutomationId = "Test1" });
74+
boxView.Behaviors.Add(behavior);
75+
boxView.GestureRecognizers.Add(new TapGestureRecognizer() { AutomationId = "Test2" });
76+
77+
var gestureRecognizers = boxView.GestureRecognizers.ToList();
78+
gestureRecognizers.Should().HaveCount(3).And.AllBeOfType<TapGestureRecognizer>();
79+
80+
behavior.AnimateOnTap = false;
81+
82+
gestureRecognizers = boxView.GestureRecognizers.ToList();
83+
gestureRecognizers.Should().HaveCount(2).And.AllBeOfType<TapGestureRecognizer>();
84+
gestureRecognizers.Select(g => ((TapGestureRecognizer)g).AutomationId).Should().BeEquivalentTo("Test1", "Test2");
5185
}
5286

5387
[Fact(Timeout = (int)TestDuration.Short)]
@@ -128,7 +162,7 @@ public void AnimateCommandTokenCanceled()
128162
await animationEndedTcs.Task;
129163
});
130164
}
131-
catch (OperationCanceledException e)
165+
catch(OperationCanceledException e)
132166
{
133167
exception = e;
134168
}
@@ -171,7 +205,7 @@ public void AnimateCommandTokenExpired()
171205
await animationEndedTcs.Task;
172206
});
173207
}
174-
catch (OperationCanceledException e)
208+
catch(OperationCanceledException e)
175209
{
176210
exception = e;
177211
}
@@ -190,6 +224,7 @@ class MockAnimation : BaseAnimation
190224
public bool HasAnimated { get; private set; }
191225

192226
public event EventHandler? AnimationStarted;
227+
193228
public event EventHandler? AnimationEnded;
194229

195230
public override async Task Animate(VisualElement element, CancellationToken token)

src/CommunityToolkit.Maui/Behaviors/AnimationBehavior.shared.cs

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,17 @@ public partial class AnimationBehavior : EventToCommandBehavior
3030
public static readonly BindableProperty AnimateCommandProperty =
3131
BindableProperty.CreateReadOnly(nameof(AnimateCommand), typeof(Command<CancellationToken>), typeof(AnimationBehavior), default, BindingMode.OneWayToSource, propertyChanging: OnAnimateCommandChanging, defaultValueCreator: CreateAnimateCommand).BindableProperty;
3232

33+
/// <summary>
34+
/// Backing BindableProperty for the <see cref="AnimateOnTap"/> property.
35+
/// </summary>
36+
public static readonly BindableProperty AnimateOnTapProperty =
37+
BindableProperty.Create(nameof(AnimateOnTap), typeof(bool), typeof(AnimationBehavior), propertyChanged: OnAnimateOnTapPropertyChanged);
38+
3339
TapGestureRecognizer? tapGestureRecognizer;
3440

3541
/// <summary>
3642
/// Gets the Command that allows the triggering of the animation.
37-
///
43+
///
3844
/// NOTE: Apps should not directly set this property, treating it as read only. The setter is only public because
3945
/// that's currently needed to make XAML Hot Reload work. Instead, apps should provide a value for this OneWayToSource
4046
/// property by creating a binding, in XAML or C#. If done via C# use code like this:
@@ -63,31 +69,25 @@ public BaseAnimation? AnimationType
6369
get => (BaseAnimation?)GetValue(AnimationTypeProperty);
6470
set => SetValue(AnimationTypeProperty, value);
6571
}
72+
73+
/// <summary>
74+
/// Whether a TapGestureRecognizer is added to the control or not
75+
/// </summary>
76+
public bool AnimateOnTap
77+
{
78+
get => (bool)GetValue(AnimateOnTapProperty);
79+
set => SetValue(AnimateOnTapProperty, value);
80+
}
6681

6782
/// <inheritdoc/>
6883
protected override void OnAttachedTo(VisualElement bindable)
6984
{
7085
base.OnAttachedTo(bindable);
7186

72-
if (!string.IsNullOrWhiteSpace(EventName))
73-
{
74-
return;
75-
}
76-
77-
if (bindable is ITextInput)
78-
{
79-
throw new InvalidOperationException($"Animation Behavior can not be attached to {nameof(ITextInput)} without using the EventName property.");
80-
}
81-
82-
if (bindable is not IGestureRecognizers gestureRecognizers)
87+
if (AnimateOnTap)
8388
{
84-
throw new InvalidOperationException($"VisualElement does not implement {nameof(IGestureRecognizers)}.");
89+
AddTapGestureRecognizer();
8590
}
86-
87-
tapGestureRecognizer = new TapGestureRecognizer();
88-
tapGestureRecognizer.Tapped += OnTriggerHandled;
89-
90-
gestureRecognizers.GestureRecognizers.Add(tapGestureRecognizer);
9191
}
9292

9393
/// <inheritdoc/>
@@ -110,6 +110,52 @@ protected override async void OnTriggerHandled(object? sender = null, object? ev
110110
base.OnTriggerHandled(sender, eventArgs);
111111
}
112112

113+
static void OnAnimateOnTapPropertyChanged(BindableObject bindable, object oldValue, object newValue)
114+
{
115+
if (bindable is not AnimationBehavior behavior)
116+
{
117+
return;
118+
}
119+
120+
if ((bool)newValue)
121+
{
122+
behavior.AddTapGestureRecognizer();
123+
}
124+
else
125+
{
126+
behavior.RemoveTapGestureRecognizer();
127+
}
128+
}
129+
130+
void AddTapGestureRecognizer()
131+
{
132+
if (View is not IGestureRecognizers gestureRecognizers)
133+
{
134+
return;
135+
}
136+
137+
tapGestureRecognizer = new TapGestureRecognizer();
138+
tapGestureRecognizer.Tapped += OnTriggerHandled;
139+
gestureRecognizers.GestureRecognizers.Add(tapGestureRecognizer);
140+
}
141+
142+
void RemoveTapGestureRecognizer()
143+
{
144+
if (tapGestureRecognizer is null)
145+
{
146+
return;
147+
}
148+
149+
if (View is not IGestureRecognizers gestureRecognizers)
150+
{
151+
return;
152+
}
153+
154+
gestureRecognizers.GestureRecognizers.Remove(tapGestureRecognizer);
155+
tapGestureRecognizer.Tapped -= OnTriggerHandled;
156+
tapGestureRecognizer = null;
157+
}
158+
113159
static Command<CancellationToken> CreateAnimateCommand(BindableObject bindable)
114160
{
115161
var animationBehavior = (AnimationBehavior)bindable;
@@ -141,7 +187,7 @@ async Task OnAnimate(CancellationToken token)
141187
// Returning the `Task` would cause the `OnAnimate()` method to return immediately, before `AnimationType.Animate()` has completed. Returning immediately exits our try/catch block and thus negates our opportunity to handle any Exceptions which breaks `Options.ShouldSuppressExceptionsInAnimations`.
142188
await AnimationType.Animate(View, token);
143189
}
144-
catch (Exception ex) when (Options.ShouldSuppressExceptionsInAnimations)
190+
catch (Exception ex) when(Options.ShouldSuppressExceptionsInAnimations)
145191
{
146192
Trace.TraceInformation("{0}", ex);
147193
}

0 commit comments

Comments
 (0)