Skip to content

Commit 960d0b5

Browse files
Add BoxView behind the Popup content to safely handle touch interaction (#2997)
* Revert back to a BoxView behind the Popup content to safely handle touch interaction * Code tidy up * Add `partial` to `PopupOverlay`, Use `in` keyword * Fix Failing Unit Tests --------- Co-authored-by: Shaun Lawrence <17139988+bijington@users.noreply.github.com> Co-authored-by: Brandon Minnick <13558917+TheCodeTraveler@users.noreply.github.com>
1 parent f44d704 commit 960d0b5

File tree

9 files changed

+173
-28
lines changed

9 files changed

+173
-28
lines changed

samples/CommunityToolkit.Maui.Sample/MauiProgram.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ static void RegisterViewsAndViewModels(in IServiceCollection services)
269269
// Add Popups
270270
services.AddTransientPopup<ApplyToDerivedTypesPopup>();
271271
services.AddTransientPopup<ButtonPopup>();
272+
services.AddTransientPopup<CollectionViewPopup, CollectionViewPopupViewModel>();
272273
services.AddTransientPopup<ComplexPopup, ComplexPopupViewModel>();
273274
services.AddTransientPopup<CsharpBindingPopup, CsharpBindingPopupViewModel>();
274275
services.AddTransientPopup<DynamicStyleInheritancePopup>();

samples/CommunityToolkit.Maui.Sample/Pages/Views/Popup/PopupsPage.xaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252

5353
<Button Text="Complex Popup" Clicked="HandleComplexPopupClicked" />
5454

55+
<Button Text="Collection View Popup" Clicked="HandleCollectionViewPopupClicked" />
56+
5557
</VerticalStackLayout>
5658
</ScrollView>
5759
</ContentPage.Content>

samples/CommunityToolkit.Maui.Sample/Pages/Views/Popup/PopupsPage.xaml.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,20 @@ async void HandleSelfClosingPopupButtonClicked(object? sender, EventArgs e)
149149

150150
await this.ClosePopupAsync();
151151
}
152+
153+
async void HandleCollectionViewPopupClicked(object? sender, EventArgs e)
154+
{
155+
var popupResult = await popupService.ShowPopupAsync<CollectionViewPopup, string>(
156+
Navigation,
157+
PopupOptions.Empty,
158+
CancellationToken.None);
159+
160+
if (!popupResult.WasDismissedByTappingOutsideOfPopup)
161+
{
162+
// Display Popup Result as a Toast
163+
await Toast.Make($"You selected {popupResult.Result}").Show(CancellationToken.None);
164+
}
165+
}
152166

153167
async void HandleComplexPopupClicked(object? sender, EventArgs e)
154168
{

samples/CommunityToolkit.Maui.Sample/Resources/Styles/Styles.xaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
<Style TargetType="VerticalStackLayout" ApplyToDerivedTypes="true">
2929
<Setter Property="Spacing" Value="0" />
3030
</Style>
31+
32+
<!-- Highlights that this style will not be inherited by a popup -->
33+
<Style TargetType="BoxView" ApplyToDerivedTypes="True">
34+
<Setter Property="BackgroundColor" Value="Yellow" />
35+
</Style>
3136

3237
<Style TargetType="Button">
3338
<Setter Property="BackgroundColor" Value="{StaticResource NormalButtonBackgroundColor}" />
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
using CommunityToolkit.Mvvm.Input;
3+
4+
namespace CommunityToolkit.Maui.Sample.ViewModels.Views;
5+
6+
public partial class CollectionViewPopupViewModel(IPopupService popupService) : ObservableObject
7+
{
8+
readonly INavigation navigation = Application.Current?.Windows[0].Page?.Navigation ?? throw new InvalidOperationException("Unable to locate INavigation");
9+
10+
bool CanReturnButtonExecute => SelectedTitle?.Length > 0;
11+
12+
[RelayCommand(CanExecute = nameof(CanReturnButtonExecute))]
13+
async Task OnReturnButtonTapped(CancellationToken token)
14+
{
15+
await popupService.ClosePopupAsync<string>(navigation, SelectedTitle ?? string.Empty, token);
16+
}
17+
18+
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(ReturnButtonTappedCommand))]
19+
public partial string? SelectedTitle { get; set; }
20+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<mct:Popup xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
4+
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
5+
xmlns:mct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
6+
xmlns:vm="clr-namespace:CommunityToolkit.Maui.Sample.ViewModels.Views"
7+
xmlns:system="clr-namespace:System;assembly=System.Runtime"
8+
x:Class="CommunityToolkit.Maui.Sample.Views.Popups.CollectionViewPopup"
9+
x:DataType="vm:CollectionViewPopupViewModel"
10+
x:TypeArguments="system:String">
11+
12+
<mct:Popup.Resources>
13+
<mct:AppThemeColor Light="Black" Dark="Black" x:Key="TextColor" />
14+
</mct:Popup.Resources>
15+
16+
<VerticalStackLayout Spacing="12">
17+
18+
<Label Text="Collection View Popup"
19+
TextColor="{mct:AppThemeResource TextColor}"
20+
FontSize="24"
21+
HorizontalTextAlignment="Center"
22+
VerticalTextAlignment="Center"
23+
HorizontalOptions="Center"
24+
VerticalOptions="Center"
25+
FontAttributes="Bold" />
26+
27+
<CollectionView HorizontalOptions="Center"
28+
VerticalOptions="Center"
29+
WidthRequest="250"
30+
SelectionMode="Single"
31+
SelectedItem="{Binding SelectedTitle}">
32+
<CollectionView.ItemsSource>
33+
<x:Array Type="{x:Type x:String}">
34+
<x:String>Welcome, Program.</x:String>
35+
<x:String>Initiating lightcycle…</x:String>
36+
<x:String>End of line.</x:String>
37+
</x:Array>
38+
</CollectionView.ItemsSource>
39+
<CollectionView.ItemTemplate>
40+
<DataTemplate x:DataType="x:String">
41+
<Border BackgroundColor="LightGray"
42+
StrokeShape="RoundRectangle 8"
43+
Padding="8"
44+
Margin="4"
45+
HorizontalOptions="Fill"
46+
VerticalOptions="Center">
47+
48+
<VisualStateManager.VisualStateGroups>
49+
<VisualStateGroup Name="CommonStates">
50+
<VisualState Name="Normal" />
51+
<VisualState Name="Selected">
52+
<VisualState.Setters>
53+
<Setter Property="BackgroundColor" Value="{StaticResource Blue100Accent}" />
54+
</VisualState.Setters>
55+
</VisualState>
56+
</VisualStateGroup>
57+
</VisualStateManager.VisualStateGroups>
58+
59+
<Label Text="{Binding .}"
60+
TextColor="Black"
61+
FontSize="16" HorizontalOptions="Center"/>
62+
</Border>
63+
</DataTemplate>
64+
</CollectionView.ItemTemplate>
65+
</CollectionView>
66+
67+
<Button Text="Return"
68+
HorizontalOptions="Center"
69+
VerticalOptions="Center"
70+
Command="{Binding ReturnButtonTappedCommand}" />
71+
72+
</VerticalStackLayout>
73+
74+
</mct:Popup>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using CommunityToolkit.Maui.Sample.ViewModels.Views;
2+
using CommunityToolkit.Maui.Views;
3+
4+
namespace CommunityToolkit.Maui.Sample.Views.Popups;
5+
6+
public partial class CollectionViewPopup : Popup<string>
7+
{
8+
public CollectionViewPopup(CollectionViewPopupViewModel viewModel)
9+
{
10+
InitializeComponent();
11+
12+
CanBeDismissedByTappingOutsideOfPopup = false;
13+
14+
BindingContext = viewModel;
15+
}
16+
}

src/CommunityToolkit.Maui.UnitTests/Views/Popup/PopupPageTests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,15 +422,16 @@ public void Constructor_WithViewAndPopupOptions_SetsCorrectProperties()
422422
Assert.Equal(PresentationMode.ModalNotAnimated, Shell.GetPresentationMode(popupPage));
423423
Assert.Equal(UIModalPresentationStyle.OverFullScreen, popupPage.On<iOS>().ModalPresentationStyle());
424424

425-
// Verify content has tap gesture recognizer attached
426-
var gestureRecognizers = popupPage.Content.GestureRecognizers;
425+
// Verify content has tap gesture recognizer overlay
426+
var gestureRecognizers = popupPage.Content.TapGestureGestureOverlay.GestureRecognizers;
427427
Assert.Single(gestureRecognizers);
428428
Assert.IsType<TapGestureRecognizer>(gestureRecognizers[0]);
429429

430430
// Verify PopupPageLayout structure
431431
var pageContent = popupPage.Content;
432-
Assert.Single(pageContent.Children);
433-
Assert.IsType<Border>(pageContent.Children.Single(), exactMatch: false);
432+
Assert.Equal(2, pageContent.Children.Count);
433+
Assert.IsType<Border>(pageContent.Children.OfType<Border>().Single(), exactMatch: false);
434+
Assert.IsType<BoxView>(pageContent.Children.OfType<PopupPage.PopupGestureOverlay>().Single(), exactMatch: false);
434435

435436
// Verify content binding context is set correctly
436437
Assert.Equal(view.BindingContext, pageContent.BindingContext);

src/CommunityToolkit.Maui/Views/Popup/PopupPage.shared.cs

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,8 @@ public PopupPage(Popup popup, IPopupOptions? popupOptions)
4646
await CloseAsync(new PopupResult(true));
4747
}, () => GetCanBeDismissedByTappingOutsideOfPopup(popup, popupOptions));
4848

49-
50-
var pageTapGestureRecognizer = new TapGestureRecognizer();
51-
pageTapGestureRecognizer.Tapped += HandleTapGestureRecognizerTapped;
52-
53-
base.Content = new PopupPageLayout(popup, popupOptions)
54-
{
55-
GestureRecognizers = { pageTapGestureRecognizer }
56-
};
49+
var popupPageLayout = new PopupPageLayout(popup, popupOptions, () => TryExecuteTapOutsideOfPopupCommand());
50+
base.Content = popupPageLayout;
5751

5852
popup.PropertyChanged += HandlePopupPropertyChanged;
5953
if (popupOptions is BindableObject bindablePopupOptions)
@@ -115,7 +109,7 @@ public async Task CloseAsync(PopupResult result, CancellationToken token = defau
115109
await Navigation.PopModalAsync(false).WaitAsync(token);
116110

117111
// Clean up Popup resources
118-
base.Content.GestureRecognizers.Clear();
112+
Content.TapGestureGestureOverlay.GestureRecognizers.Clear();
119113
popup.PropertyChanged -= HandlePopupPropertyChanged;
120114

121115
PopupClosed?.Invoke(this, result);
@@ -203,29 +197,22 @@ void IQueryAttributable.ApplyQueryAttributes(IDictionary<string, object> query)
203197
}
204198
}
205199

206-
void HandleTapGestureRecognizerTapped(object? sender, TappedEventArgs e)
200+
internal sealed partial class PopupGestureOverlay : BoxView
207201
{
208-
ArgumentNullException.ThrowIfNull(sender);
209-
210-
var popupPageLayout = (PopupPageLayout)sender;
211-
var position = e.GetPosition(Content);
212-
213-
if (position is null)
214-
{
215-
return;
216-
}
217-
218-
// Execute tapOutsideOfPopupCommand only if tap occurred outside the PopupBorder
219-
if (popupPageLayout.PopupBorder.Bounds.Contains(position.Value) is false)
202+
public PopupGestureOverlay()
220203
{
221-
TryExecuteTapOutsideOfPopupCommand();
204+
BackgroundColor = Colors.Transparent;
205+
Background = Brush.Transparent;
222206
}
223207
}
224208

225209
internal sealed partial class PopupPageLayout : Grid
226210
{
227-
public PopupPageLayout(in Popup popupContent, in IPopupOptions options)
211+
readonly Action tryExecuteTapOutsideOfPopupCommand;
212+
213+
public PopupPageLayout(in Popup popupContent, in IPopupOptions options, in Action tryExecuteTapOutsideOfPopupCommand)
228214
{
215+
this.tryExecuteTapOutsideOfPopupCommand = tryExecuteTapOutsideOfPopupCommand;
229216
Background = BackgroundColor = null;
230217

231218
PopupBorder = new Border
@@ -247,10 +234,35 @@ public PopupPageLayout(in Popup popupContent, in IPopupOptions options)
247234
PopupBorder.SetBinding(Border.StrokeShapeProperty, static (IPopupOptions options) => options.Shape, source: options, mode: BindingMode.OneWay);
248235
PopupBorder.SetBinding(Border.StrokeThicknessProperty, static (IPopupOptions options) => options.Shape, source: options, mode: BindingMode.OneWay, converter: new BorderStrokeThicknessConverter());
249236

237+
var overlayTapGestureRecognizer = new TapGestureRecognizer();
238+
overlayTapGestureRecognizer.Tapped += HandleOverlayTapped;
239+
TapGestureGestureOverlay = new PopupGestureOverlay();
240+
TapGestureGestureOverlay.GestureRecognizers.Add(overlayTapGestureRecognizer);
241+
242+
Children.Add(TapGestureGestureOverlay);
250243
Children.Add(PopupBorder);
251244
}
245+
246+
void HandleOverlayTapped(object? sender, TappedEventArgs e)
247+
{
248+
ArgumentNullException.ThrowIfNull(sender);
249+
250+
var position = e.GetPosition(this);
251+
252+
if (position is null)
253+
{
254+
return;
255+
}
256+
257+
// Execute tapOutsideOfPopupCommand only if tap occurred outside the PopupBorder
258+
if (PopupBorder.Bounds.Contains(position.Value) is false)
259+
{
260+
tryExecuteTapOutsideOfPopupCommand();
261+
}
262+
}
252263

253264
public Border PopupBorder { get; }
265+
public PopupGestureOverlay TapGestureGestureOverlay { get; }
254266

255267
sealed partial class BorderStrokeThicknessConverter : BaseConverterOneWay<Shape?, double>
256268
{

0 commit comments

Comments
 (0)