diff --git a/.editorconfig b/.editorconfig
index db5b05678..91146ee65 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -47,4 +47,7 @@ csharp_style_namespace_declarations = file_scoped:error
dotnet_diagnostic.CS4014.severity = error
# Remove explicit default access modifiers
-dotnet_style_require_accessibility_modifiers = omit_if_default:error
\ No newline at end of file
+dotnet_style_require_accessibility_modifiers = omit_if_default:error
+
+# CA1063: Implement IDisposable Correctly
+dotnet_diagnostic.CA1063.severity = error
\ No newline at end of file
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index ad032e88b..11b5764f1 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -38,11 +38,6 @@ jobs:
pool:
vmImage: windows-latest
steps:
- # .NET 5.0 is required for maui-check
- - task: UseDotNet@2
- displayName: 'Install .NET 5.0 SDK'
- inputs:
- version: '5.0.x'
- task: UseDotNet@2
displayName: 'Install .NET SDK'
inputs:
@@ -77,7 +72,7 @@ jobs:
- powershell: |
$prNumber = $env:System_PullRequest_PullRequestNumber
$commitId = "$($env:System_PullRequest_SourceCommitId)".Substring(0, 7)
- $fullVersionString = "$(CurrentSemanticVersionBase)-build.$prNumber+$commitId"
+ $fullVersionString = "$(CurrentSemanticVersionBase)-build-$prNumber.$(Build.BuildId)+$commitId"
Write-Host("GitHub PR = $prNumber, Commit = $commitId");
Write-Host ("##vso[task.setvariable variable=NugetPackageVersion;]$fullVersionString")
Write-Host "##vso[build.updatebuildnumber]$fullVersionString"
@@ -96,15 +91,13 @@ jobs:
inputs:
script: 'dotnet build $(PathToCommunityToolkitCsproj) -c Release'
- task: CmdLine@2
- displayName: 'Build Community Toolkit Sample'
+ displayName: 'Run Unit Tests'
inputs:
- script: 'dotnet build $(PathToCommunityToolkitSampleCsproj) -c Release'
+ script: 'dotnet test $(PathToCommunityToolkitUnitTestCsproj) -c Release'
- task: CmdLine@2
- displayName: 'Run Unit Tests'
+ displayName: 'Build Community Toolkit Sample'
inputs:
- script: |
- dotnet build $(PathToCommunityToolkitUnitTestCsproj) -c Release
- dotnet test $(PathToCommunityToolkitUnitTestCsproj) -c Release
+ script: 'dotnet build $(PathToCommunityToolkitSampleCsproj) -c Release'
- task: CmdLine@2
displayName: Pack Community Toolkit NuGets
inputs:
@@ -163,15 +156,13 @@ jobs:
inputs:
script: 'dotnet build $(PathToCommunityToolkitCsproj) -c Release'
- task: CmdLine@2
- displayName: 'Build Community Toolkit Sample'
+ displayName: 'Run Unit Tests'
inputs:
- script: 'dotnet build $(PathToCommunityToolkitSampleCsproj) -c Release'
+ script: 'dotnet test $(PathToCommunityToolkitUnitTestCsproj) -c Release'
- task: CmdLine@2
- displayName: 'Run Unit Tests'
+ displayName: 'Build Community Toolkit Sample'
inputs:
- script: |
- dotnet build $(PathToCommunityToolkitUnitTestCsproj) -c Release
- dotnet test $(PathToCommunityToolkitUnitTestCsproj) -c Release
+ script: 'dotnet build $(PathToCommunityToolkitSampleCsproj) -c Release'
- task: CmdLine@2
displayName: 'Pack CommunityToolkit NuGets'
inputs:
diff --git a/samples/CommunityToolkit.Maui.Sample.sln b/samples/CommunityToolkit.Maui.Sample.sln
index 0fba7d0ac..6de6e6777 100644
--- a/samples/CommunityToolkit.Maui.Sample.sln
+++ b/samples/CommunityToolkit.Maui.Sample.sln
@@ -13,9 +13,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Maui.Sampl
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3ED2C978-9DDB-48FE-8C5A-521B254F18A3}"
ProjectSection(SolutionItems) = preProject
+ ..\.editorconfig = ..\.editorconfig
+ ..\azure-pipelines.yml = ..\azure-pipelines.yml
..\Directory.Build.props = ..\Directory.Build.props
..\global.json = ..\global.json
- ..\.editorconfig = ..\.editorconfig
EndProjectSection
EndProject
Global
diff --git a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj
index f34180d65..5797720af 100644
--- a/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj
+++ b/samples/CommunityToolkit.Maui.Sample/CommunityToolkit.Maui.Sample.csproj
@@ -40,7 +40,7 @@
-
+
@@ -67,4 +67,8 @@
win-x64
+
+ android-arm;android-arm64;android-x86;android-x64
+
+
diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/AlertsGalleryPage.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/AlertsGalleryPage.cs
new file mode 100644
index 000000000..448faa270
--- /dev/null
+++ b/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/AlertsGalleryPage.cs
@@ -0,0 +1,10 @@
+using CommunityToolkit.Maui.Sample.ViewModels.Alerts;
+
+namespace CommunityToolkit.Maui.Sample.Pages.Alerts;
+
+public class AlertsGalleryPage : BaseGalleryPage
+{
+ public AlertsGalleryPage() : base("Alerts")
+ {
+ }
+}
\ No newline at end of file
diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/SnackbarPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/SnackbarPage.xaml
new file mode 100644
index 000000000..102f8d098
--- /dev/null
+++ b/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/SnackbarPage.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/SnackbarPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/SnackbarPage.xaml.cs
new file mode 100644
index 000000000..a93cf8192
--- /dev/null
+++ b/samples/CommunityToolkit.Maui.Sample/Pages/Alerts/SnackbarPage.xaml.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using CommunityToolkit.Maui.Alerts.Snackbar;
+using CommunityToolkit.Maui.Extensions;
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+
+namespace CommunityToolkit.Maui.Sample.Pages.Alerts;
+
+public partial class SnackbarPage : BasePage
+{
+ const string _displayCustomSnackbarText = "Display a Custom Snackbar, Anchored to this Button";
+ const string _dismissCustomSnackbarText = "Dismiss Custom Snackbar";
+ readonly IReadOnlyList _colors = typeof(Colors)
+ .GetFields(BindingFlags.Static | BindingFlags.Public)
+ .ToDictionary(c => c.Name, c => (Color)(c.GetValue(null) ?? throw new InvalidOperationException()))
+ .Values.ToList();
+
+ ISnackbar? _customSnackbar;
+
+ public SnackbarPage()
+ {
+ InitializeComponent();
+
+ DisplayCustomSnackbarButton ??= new();
+ DisplayCustomSnackbarButton.Text = _displayCustomSnackbarText;
+ }
+
+ async void DisplayDefaultSnackbarButtonClicked(object? sender, EventArgs args) =>
+ await this.DisplaySnackbar("This is a Snackbar.\nIt will disappear in 3 seconds.\nOr click OK to dismiss immediately");
+
+ async void DisplayCustomSnackbarButtonClicked(object? sender, EventArgs args)
+ {
+ if (DisplayCustomSnackbarButton.Text is _displayCustomSnackbarText)
+ {
+ var options = new SnackbarOptions
+ {
+ BackgroundColor = Colors.Red,
+ TextColor = Colors.Green,
+ ActionButtonTextColor = Colors.Yellow,
+ CornerRadius = new CornerRadius(10),
+ Font = Font.SystemFontOfSize(14),
+ };
+
+ _customSnackbar = Snackbar.Make(
+ "This is a customized Snackbar",
+ async () =>
+ {
+ await DisplayCustomSnackbarButton.BackgroundColorTo(_colors[new Random().Next(_colors.Count)], length: 500);
+ DisplayCustomSnackbarButton.Text = _displayCustomSnackbarText;
+ },
+ "Change Button Color",
+ TimeSpan.FromSeconds(30),
+ options,
+ DisplayCustomSnackbarButton);
+
+ await _customSnackbar.Show();
+
+ DisplayCustomSnackbarButton.Text = _dismissCustomSnackbarText;
+ }
+ else if (DisplayCustomSnackbarButton.Text is _dismissCustomSnackbarText)
+ {
+ if (_customSnackbar is not null)
+ await _customSnackbar.Dismiss();
+
+ DisplayCustomSnackbarButton.Text = _displayCustomSnackbarText;
+ }
+ else
+ {
+ throw new NotImplementedException($"{nameof(DisplayCustomSnackbarButton)}.{nameof(ITextButton.Text)} Not Recognized");
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/CommunityToolkit.Maui.Sample/Platforms/Android/MainActivity.cs b/samples/CommunityToolkit.Maui.Sample/Platforms/Android/MainActivity.cs
index c7430b367..cc64ca70f 100644
--- a/samples/CommunityToolkit.Maui.Sample/Platforms/Android/MainActivity.cs
+++ b/samples/CommunityToolkit.Maui.Sample/Platforms/Android/MainActivity.cs
@@ -1,5 +1,6 @@
using Android.App;
using Android.Content.PM;
+using Android.OS;
using Microsoft.Maui;
namespace CommunityToolkit.Maui.Sample;
@@ -7,4 +8,10 @@ namespace CommunityToolkit.Maui.Sample;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize)]
public class MainActivity : MauiAppCompatActivity
{
+ // https://github.com/dotnet/maui/pull/3345
+ protected override void OnCreate(Bundle? savedInstanceState)
+ {
+ base.OnCreate(savedInstanceState);
+ Microsoft.Maui.Essentials.Platform.Init(this, savedInstanceState);
+ }
}
\ No newline at end of file
diff --git a/samples/CommunityToolkit.Maui.Sample/Platforms/Windows/Package.appxmanifest b/samples/CommunityToolkit.Maui.Sample/Platforms/Windows/Package.appxmanifest
index 86413af49..1f3c7eb42 100644
--- a/samples/CommunityToolkit.Maui.Sample/Platforms/Windows/Package.appxmanifest
+++ b/samples/CommunityToolkit.Maui.Sample/Platforms/Windows/Package.appxmanifest
@@ -3,8 +3,9 @@
+ IgnorableNamespaces="uap rescap desktop4">
-
CreateItems() => new[]
+ {
+ new SectionModel(typeof(SnackbarPage), "Snackbar", "Show Snackbar Alert")
+ };
+}
\ No newline at end of file
diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/MainViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/MainViewModel.cs
index 8901bb7f8..133bc8b8c 100644
--- a/samples/CommunityToolkit.Maui.Sample/ViewModels/MainViewModel.cs
+++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/MainViewModel.cs
@@ -3,6 +3,7 @@
using CommunityToolkit.Maui.Sample.Pages.Behaviors;
using CommunityToolkit.Maui.Sample.Pages.Converters;
using CommunityToolkit.Maui.Sample.Pages.Extensions;
+using CommunityToolkit.Maui.Sample.Pages.Alerts;
using Microsoft.Maui.Graphics;
namespace CommunityToolkit.Maui.Sample.ViewModels;
@@ -19,5 +20,8 @@ protected override IEnumerable CreateItems() => new[]
new SectionModel(typeof(ExtensionsGalleryPage), "Extensions", Color.FromArgb("#00EA56"),
"Extenions lets you add methods to existing types without creating a new derived type, recompiling, or otherwise modifying the original type."),
+
+ new SectionModel(typeof(AlertsGalleryPage), "Alerts", Color.FromArgb("#EF6950"),
+ "Alerts allow you display alerts to your user"),
};
}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.UnitTests/Alerts/SnackBar_Tests.cs b/src/CommunityToolkit.Maui.UnitTests/Alerts/SnackBar_Tests.cs
new file mode 100644
index 000000000..c663a0e0a
--- /dev/null
+++ b/src/CommunityToolkit.Maui.UnitTests/Alerts/SnackBar_Tests.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using CommunityToolkit.Maui.Alerts.Snackbar;
+using FluentAssertions;
+using Microsoft.Maui;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Graphics;
+using Xunit;
+
+namespace CommunityToolkit.Maui.UnitTests.Alerts;
+
+public class Snackbar_Tests : BaseTest
+{
+ readonly ISnackbar _snackbar = new Snackbar();
+
+ [Fact]
+ public async Task SnackbarShow_IsShownTrue()
+ {
+ await _snackbar.Show();
+ Assert.True(Snackbar.IsShown);
+ }
+
+ [Fact]
+ public async Task SnackbarDismissed_IsShownFalse()
+ {
+ await _snackbar.Dismiss();
+ Assert.False(Snackbar.IsShown);
+ }
+
+ [Fact]
+ public async Task SnackbarShow_ShownEventRaised()
+ {
+ var receivedEvents = new List();
+ Snackbar.Shown += (sender, e) =>
+ {
+ receivedEvents.Add(e);
+ };
+ await _snackbar.Show();
+ Assert.Single(receivedEvents);
+ }
+
+ [Fact]
+ public async Task SnackbarDismiss_DismissedEventRaised()
+ {
+ var receivedEvents = new List();
+ Snackbar.Dismissed += (sender, e) =>
+ {
+ receivedEvents.Add(e);
+ };
+ await _snackbar.Dismiss();
+ Assert.Single(receivedEvents);
+ }
+
+ [Fact]
+ public async Task VisualElement_DisplaySnackbar_ShownEventReceived()
+ {
+ var receivedEvents = new List();
+ Snackbar.Shown += (sender, e) =>
+ {
+ receivedEvents.Add(e);
+ };
+ var button = new Button();
+ await button.DisplaySnackbar("message");
+ Assert.Single(receivedEvents);
+ }
+
+ [Fact]
+ public async Task SnackbarMake_NewSnackbarCreatedWithValidProperties()
+ {
+ var action = () => { };
+ var anchor = new Button();
+ var expectedSnackbar = new Snackbar
+ {
+ Anchor = anchor,
+ Action = action,
+ Duration = TimeSpan.MaxValue,
+ Text = "Test",
+ ActionButtonText = "Ok",
+ VisualOptions = new SnackbarOptions
+ {
+ Font = Font.Default,
+ BackgroundColor = Colors.Red,
+ CharacterSpacing = 10,
+ CornerRadius = new CornerRadius(1,2,3,4),
+ TextColor = Colors.RosyBrown,
+ ActionButtonFont = Font.SystemFontOfSize(5),
+ ActionButtonTextColor = Colors.Aqua
+ }
+ };
+
+ var currentSnackbar = Snackbar.Make(
+ "Test",
+ action,
+ "Ok",
+ TimeSpan.MaxValue,
+ new SnackbarOptions
+ {
+ Font = Font.Default,
+ BackgroundColor = Colors.Red,
+ CharacterSpacing = 10,
+ CornerRadius = new CornerRadius(1,2,3,4),
+ TextColor = Colors.RosyBrown,
+ ActionButtonFont = Font.SystemFontOfSize(5),
+ ActionButtonTextColor = Colors.Aqua
+ },
+ anchor);
+
+ currentSnackbar.Should().BeEquivalentTo(expectedSnackbar);
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui.UnitTests/BaseTest.cs b/src/CommunityToolkit.Maui.UnitTests/BaseTest.cs
index acf7242d3..1b707e829 100644
--- a/src/CommunityToolkit.Maui.UnitTests/BaseTest.cs
+++ b/src/CommunityToolkit.Maui.UnitTests/BaseTest.cs
@@ -21,6 +21,12 @@ protected BaseTest()
~BaseTest() => Dispose(false);
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
protected virtual void Dispose(bool isDisposing)
{
if (_isDisposed)
@@ -34,12 +40,6 @@ protected virtual void Dispose(bool isDisposing)
_isDisposed = true;
}
- void IDisposable.Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
-
protected static Stream GetStreamFromImageSource(ImageSource imageSource)
{
var streamImageSource = (StreamImageSource)imageSource;
diff --git a/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj b/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj
index 864e32b8c..6a3179ad0 100644
--- a/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj
+++ b/src/CommunityToolkit.Maui.UnitTests/CommunityToolkit.Maui.UnitTests.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/CommunityToolkit.Maui/Alerts/Snackbar/ISnackbar.shared.cs b/src/CommunityToolkit.Maui/Alerts/Snackbar/ISnackbar.shared.cs
new file mode 100644
index 000000000..327131368
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Alerts/Snackbar/ISnackbar.shared.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.Maui;
+
+namespace CommunityToolkit.Maui.Alerts.Snackbar;
+
+///
+/// Snackbar
+///
+public interface ISnackbar : IAsyncDisposable
+{
+ ///
+ /// Action to invoke on action button click
+ ///
+ Action? Action { get; }
+
+ ///
+ /// Snackbar action button text
+ ///
+ string ActionButtonText { get; }
+
+ ///
+ /// Snackbar anchor. Snackbar appears near this view
+ ///
+ IView? Anchor { get; }
+
+ ///
+ /// Snackbar duration
+ ///
+ TimeSpan Duration { get; }
+
+ ///
+ /// Snackbar message
+ ///
+ string Text { get; }
+
+ ///
+ /// Snackbar visual options
+ ///
+ SnackbarOptions VisualOptions { get; }
+
+ ///
+ /// Dismiss the snackbar
+ ///
+ Task Dismiss();
+
+ ///
+ /// Show the snackbar
+ ///
+ Task Show();
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Alerts/Snackbar/SnackBar.shared.cs b/src/CommunityToolkit.Maui/Alerts/Snackbar/SnackBar.shared.cs
new file mode 100644
index 000000000..653fd005a
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Alerts/Snackbar/SnackBar.shared.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.Maui;
+using Microsoft.Maui.Controls;
+
+namespace CommunityToolkit.Maui.Alerts.Snackbar;
+
+///
+public partial class Snackbar : ISnackbar
+{
+ static readonly WeakEventManager _weakEventManager = new();
+
+ ///
+ /// Initializes a new instance of
+ ///
+ public Snackbar()
+ {
+ Text = string.Empty;
+ Duration = GetDefaultTimeSpan();
+ ActionButtonText = "OK";
+ VisualOptions = new SnackbarOptions();
+ }
+
+ ///
+ public SnackbarOptions VisualOptions { get; set; }
+
+ ///
+ public string Text { get; set; }
+
+ ///
+ public TimeSpan Duration { get; set; }
+
+ ///
+ public Action? Action { get; set; }
+
+ ///
+ public string ActionButtonText { get; set; }
+
+ ///
+ public static bool IsShown { get; private set; }
+
+ ///
+ public IView? Anchor { get; set; }
+
+ ///
+ public static event EventHandler Shown
+ {
+ add => _weakEventManager.AddEventHandler(value);
+ remove => _weakEventManager.RemoveEventHandler(value);
+ }
+
+ ///
+ public static event EventHandler Dismissed
+ {
+ add => _weakEventManager.AddEventHandler(value);
+ remove => _weakEventManager.RemoveEventHandler(value);
+ }
+
+ ///
+ /// Create new Snackbar
+ ///
+ /// Snackbar message
+ /// Snackbar action button text
+ /// Snackbar duration
+ /// Snackbar action
+ /// Snackbar visual options
+ /// Snackbar anchor
+ /// New instance of Snackbar
+ public static ISnackbar Make(
+ string message,
+ Action? action = null,
+ string actionButtonText = "OK",
+ TimeSpan? duration = null,
+ SnackbarOptions? visualOptions = null,
+ IView? anchor = null)
+ {
+ return new Snackbar
+ {
+ Text = message,
+ Action = action,
+ ActionButtonText = actionButtonText,
+ Duration = duration ?? GetDefaultTimeSpan(),
+ VisualOptions = visualOptions ?? new(),
+ Anchor = anchor
+ };
+ }
+
+#if NET6_0
+ ///
+ /// Show Snackbar
+ ///
+ public virtual Task Show()
+ {
+ OnShown();
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Dismiss Snackbar
+ ///
+ public virtual Task Dismiss()
+ {
+ OnDismissed();
+ return Task.CompletedTask;
+ }
+#endif
+
+ ///
+ /// Dispose Snackbar
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await DisposeAsyncCore();
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Dispose Snackbar
+ ///
+ protected virtual async ValueTask DisposeAsyncCore()
+ {
+#if NET6_0_ANDROID || NET6_0_IOS || NET6_0_MACCATALYST
+ await Microsoft.Maui.Controls.Device.InvokeOnMainThreadAsync(() => _nativeSnackbar?.Dispose());
+#else
+ await Task.CompletedTask;
+#endif
+ }
+
+ static TimeSpan GetDefaultTimeSpan() => TimeSpan.FromSeconds(3);
+
+ void OnShown()
+ {
+ IsShown = true;
+ _weakEventManager.HandleEvent(this, EventArgs.Empty, nameof(Shown));
+ }
+
+ void OnDismissed()
+ {
+ IsShown = false;
+ _weakEventManager.HandleEvent(this, EventArgs.Empty, nameof(Dismissed));
+ }
+}
+
+///
+/// Extension methods for .
+///
+public static class SnackbarVisualElementExtension
+{
+ ///
+ /// Display snackbar with the anchor
+ ///
+ /// Anchor element
+ /// Text of the snackbar
+ /// Text of the snackbar button
+ /// Action of the snackbar button
+ /// Snackbar duration
+ /// Snackbar visual options
+ public static Task DisplaySnackbar(
+ this VisualElement? visualElement,
+ string message,
+ Action? action = null,
+ string actionButtonText = "OK",
+ TimeSpan? duration = null,
+ SnackbarOptions? visualOptions = null) => Snackbar.Make(message, action, actionButtonText, duration, visualOptions, visualElement).Show();
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.android.cs b/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.android.cs
new file mode 100644
index 000000000..d61e49c83
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.android.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Android.Graphics;
+using Android.Graphics.Drawables;
+using Android.Util;
+using Android.Widget;
+using Google.Android.Material.Snackbar;
+using Microsoft.Maui;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Controls.Compatibility.Platform.Android;
+using Microsoft.Maui.Controls.Platform;
+using AndroidSnackbar = Google.Android.Material.Snackbar.Snackbar;
+using Object = Java.Lang.Object;
+using View = Android.Views.View;
+
+namespace CommunityToolkit.Maui.Alerts.Snackbar;
+
+public partial class Snackbar
+{
+ static readonly SemaphoreSlim _semaphoreSlim = new(1, 1);
+
+ AndroidSnackbar? _nativeSnackbar;
+ TaskCompletionSource? _dismissedTCS;
+
+ ///
+ /// Dismiss Snackbar
+ ///
+ public async Task Dismiss()
+ {
+ if (_nativeSnackbar is null)
+ {
+ _dismissedTCS = null;
+ return;
+ }
+
+ await _semaphoreSlim.WaitAsync();
+
+ try
+ {
+ _nativeSnackbar.Dismiss();
+ if (_dismissedTCS is not null)
+ await _dismissedTCS.Task;
+
+ OnDismissed();
+ }
+ finally
+ {
+ _semaphoreSlim.Release();
+ }
+ }
+
+ ///
+ /// Show Snackbar
+ ///
+ public async Task Show()
+ {
+ await Dismiss();
+
+ var rootView = Microsoft.Maui.Essentials.Platform.GetCurrentActivity(true).Window?.DecorView.FindViewById(Android.Resource.Id.Content);
+ if (rootView is null)
+ throw new NotSupportedException("Unable to retrieve snackbar parent");
+
+ _nativeSnackbar = AndroidSnackbar.Make(rootView, Text, (int)Duration.TotalMilliseconds);
+ var snackbarView = _nativeSnackbar.View;
+
+ if (Anchor is not Page)
+ {
+ _nativeSnackbar.SetAnchorView(Anchor?.Handler?.NativeView as View);
+ }
+
+ SetupContainer(VisualOptions, snackbarView);
+ SetupMessage(VisualOptions, snackbarView);
+ SetupActions(_nativeSnackbar);
+
+ _nativeSnackbar.Show();
+
+ OnShown();
+ }
+
+ static void SetupContainer(SnackbarOptions snackbarOptions, View snackbarView)
+ {
+ if (snackbarView.Background is GradientDrawable shape)
+ {
+ shape.SetColor(snackbarOptions.BackgroundColor.ToAndroid().ToArgb());
+
+ var density = snackbarView.Context?.Resources?.DisplayMetrics?.Density ?? 1;
+ var cornerRadius = new Thickness(
+ snackbarOptions.CornerRadius.BottomLeft * density,
+ snackbarOptions.CornerRadius.TopLeft * density,
+ snackbarOptions.CornerRadius.TopRight * density,
+ snackbarOptions.CornerRadius.BottomRight * density);
+ shape.SetCornerRadii(new[]
+ {
+ (float)cornerRadius.Left, (float)cornerRadius.Left,
+ (float)cornerRadius.Top, (float)cornerRadius.Top,
+ (float)cornerRadius.Right, (float)cornerRadius.Right,
+ (float)cornerRadius.Bottom, (float)cornerRadius.Bottom
+ });
+
+ snackbarView.SetBackground(shape);
+ }
+ }
+
+ static void SetupMessage(SnackbarOptions snackbarOptions, View snackbarView)
+ {
+ var snackTextView = snackbarView.FindViewById(Resource.Id.snackbar_text) ?? throw new InvalidOperationException("Unable to find Snackbar text view");
+ snackTextView.SetMaxLines(10);
+
+ snackTextView.SetTextColor(snackbarOptions.TextColor.ToAndroid());
+ if (snackbarOptions.Font.Size > 0)
+ {
+ snackTextView.SetTextSize(ComplexUnitType.Dip, (float)snackbarOptions.Font.Size);
+ }
+
+ snackTextView.LetterSpacing = (float)snackbarOptions.CharacterSpacing;
+
+ snackTextView.SetTypeface(snackbarOptions.Font.ToTypeface(), TypefaceStyle.Normal);
+ }
+
+ [MemberNotNull(nameof(_dismissedTCS))]
+ void SetupActions(AndroidSnackbar nativeSnackbar)
+ {
+ var snackActionButtonView = nativeSnackbar.View.FindViewById(Resource.Id.snackbar_action) ?? throw new InvalidOperationException("Unable to find Snackbar action button");
+ snackActionButtonView.SetTypeface(VisualOptions.ActionButtonFont.ToTypeface(), TypefaceStyle.Normal);
+
+ nativeSnackbar.SetAction(ActionButtonText, _ =>
+ {
+ Action?.Invoke();
+ });
+ nativeSnackbar.SetActionTextColor(VisualOptions.ActionButtonTextColor.ToAndroid());
+
+ nativeSnackbar.AddCallback(new SnackbarCallback(_dismissedTCS = new()));
+ }
+
+ class SnackbarCallback : BaseTransientBottomBar.BaseCallback
+ {
+ readonly TaskCompletionSource _dismissedTCS;
+
+ public SnackbarCallback(in TaskCompletionSource dismissedTCS)
+ {
+ _dismissedTCS = dismissedTCS;
+ }
+
+ public override void OnDismissed(Object transientBottomBar, int e)
+ {
+ base.OnDismissed(transientBottomBar, e);
+ _dismissedTCS.SetResult(true);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.ios.macos.cs b/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.ios.macos.cs
new file mode 100644
index 000000000..2172c4b90
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.ios.macos.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Maui.Alerts.Toast;
+using CoreGraphics;
+using Microsoft.Maui;
+using UIKit;
+
+namespace CommunityToolkit.Maui.Alerts.Snackbar;
+
+public partial class Snackbar
+{
+ readonly SemaphoreSlim _semaphoreSlim = new(1, 1);
+
+ static SnackbarView? _nativeSnackbar;
+
+ ///
+ /// Dismiss Snackbar
+ ///
+ public async Task Dismiss()
+ {
+ if (_nativeSnackbar is null)
+ return;
+
+ await _semaphoreSlim.WaitAsync();
+
+ try
+ {
+ _nativeSnackbar.Dismiss();
+ _nativeSnackbar = null;
+
+ OnDismissed();
+ }
+ finally
+ {
+ _semaphoreSlim.Release();
+ }
+ }
+
+ ///
+ /// Show Snackbar
+ ///
+ public async Task Show()
+ {
+ await Dismiss();
+
+ var cornerRadius = GetCornerRadius(VisualOptions.CornerRadius);
+ var padding = GetMaximum(cornerRadius.X, cornerRadius.Y, cornerRadius.Width, cornerRadius.Height) + SnackbarView.DefaultPadding;
+ _nativeSnackbar = new SnackbarView(Text,
+ VisualOptions.BackgroundColor.ToNative(),
+ cornerRadius,
+ VisualOptions.TextColor.ToNative(),
+ UIFont.SystemFontOfSize((float)VisualOptions.Font.Size),
+ VisualOptions.CharacterSpacing,
+ ActionButtonText,
+ VisualOptions.ActionButtonTextColor.ToNative(),
+ UIFont.SystemFontOfSize((float)VisualOptions.ActionButtonFont.Size),
+ padding)
+ {
+ Action = Action,
+ Anchor = Anchor?.Handler?.NativeView as UIView,
+ Duration = Duration
+ };
+
+ _nativeSnackbar.Show();
+
+ OnShown();
+
+ static T? GetMaximum(params T[] items) => items.Max();
+ }
+
+ static CGRect GetCornerRadius(CornerRadius cornerRadius)
+ {
+ return new CGRect(cornerRadius.BottomLeft, cornerRadius.TopLeft, cornerRadius.TopRight, cornerRadius.BottomRight);
+ }
+
+ sealed class SnackbarView : ToastView, IDisposable
+ {
+ readonly PaddedButton _actionButton;
+
+ public SnackbarView(
+ string message,
+ UIColor backgroundColor,
+ CGRect cornerRadius,
+ UIColor textColor,
+ UIFont textFont,
+ double characterSpacing,
+ string actionButtonText,
+ UIColor actionTextColor,
+ UIFont actionButtonFont,
+ double padding = DefaultPadding)
+ : base(message, backgroundColor, cornerRadius, textColor, textFont, characterSpacing, padding)
+ {
+ _actionButton = new PaddedButton(padding, padding, padding, padding);
+ ActionButtonText = actionButtonText;
+ ActionTextColor = actionTextColor;
+ ActionButtonFont = actionButtonFont;
+
+ _actionButton.TouchUpInside += ActionButton_TouchUpInside;
+ PopupView.AddChild(_actionButton);
+ }
+
+ public Action? Action { get; init; }
+
+ public string ActionButtonText
+ {
+ get => _actionButton.Title(UIControlState.Normal);
+ private init => _actionButton.SetTitle(value, UIControlState.Normal);
+ }
+
+ public UIColor ActionTextColor
+ {
+ get => _actionButton.TitleColor(UIControlState.Normal);
+ private init => _actionButton.SetTitleColor(value, UIControlState.Normal);
+ }
+
+ public UIFont ActionButtonFont
+ {
+ get => _actionButton.Font;
+ private init => _actionButton.Font = value;
+ }
+
+ void ActionButton_TouchUpInside(object? sender, EventArgs e)
+ {
+ Action?.Invoke();
+ PopupView.Dismiss();
+ }
+
+ public void Dispose()
+ {
+ _actionButton.TouchUpInside -= ActionButton_TouchUpInside;
+ }
+
+ class PaddedButton : UIButton
+ {
+ public PaddedButton(double left, double top, double right, double bottom)
+ {
+ Left = left;
+ Top = top;
+ Right = right;
+ Bottom = bottom;
+ ContentEdgeInsets = new UIEdgeInsets((nfloat)top, (nfloat)left, (nfloat)bottom, (nfloat)right);
+ }
+
+ public double Left { get; }
+
+ public double Top { get; }
+
+ public double Right { get; }
+
+ public double Bottom { get; }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.windows.cs b/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.windows.cs
new file mode 100644
index 000000000..d35585597
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Alerts/Snackbar/Snackbar.windows.cs
@@ -0,0 +1,81 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Toolkit.Uwp.Notifications;
+using Windows.UI.Notifications;
+
+namespace CommunityToolkit.Maui.Alerts.Snackbar;
+
+public partial class Snackbar
+{
+ readonly static SemaphoreSlim _semaphoreSlim = new(1, 1);
+
+ static ToastNotification? _nativeSnackbar;
+ TaskCompletionSource? _dismissedTCS;
+
+ ///
+ /// Dismiss Snackbar
+ ///
+ public async Task Dismiss()
+ {
+ if (_nativeSnackbar is null)
+ {
+ _dismissedTCS = null;
+ return;
+ }
+
+ await _semaphoreSlim.WaitAsync();
+
+ try
+ {
+ ToastNotificationManagerCompat.History.Clear();
+
+ _nativeSnackbar.Activated -= OnActivated;
+ _nativeSnackbar.Dismissed -= OnDismissed;
+ _nativeSnackbar.ExpirationTime = System.DateTimeOffset.Now;
+
+ _nativeSnackbar = null;
+
+ await (_dismissedTCS?.Task ?? Task.CompletedTask);
+
+ OnDismissed();
+ }
+ finally
+ {
+ _semaphoreSlim.Release();
+ }
+ }
+
+ ///
+ /// Show Snackbar
+ ///
+ public async Task Show()
+ {
+ await Dismiss();
+
+ var toastContentBuilder = new ToastContentBuilder()
+ .AddText(Text)
+ .AddButton(new ToastButton { ActivationType = ToastActivationType.Foreground }.SetContent(ActionButtonText));
+
+ var toastContent = toastContentBuilder.GetToastContent();
+ toastContent.ActivationType = ToastActivationType.Background;
+
+ _dismissedTCS = new();
+
+ _nativeSnackbar = new ToastNotification(toastContent.GetXml());
+ _nativeSnackbar.Activated += OnActivated;
+ _nativeSnackbar.Dismissed += OnDismissed;
+ _nativeSnackbar.ExpirationTime = System.DateTime.Now.Add(Duration);
+
+ ToastNotificationManager.CreateToastNotifier().Show(_nativeSnackbar);
+
+ OnShown();
+ }
+
+ void OnActivated(ToastNotification sender, object args)
+ {
+ if (_nativeSnackbar is not null && Action is not null)
+ Microsoft.Maui.Controls.Device.BeginInvokeOnMainThread(Action);
+ }
+
+ void OnDismissed(ToastNotification sender, ToastDismissedEventArgs args) => _dismissedTCS?.TrySetResult(true);
+}
diff --git a/src/CommunityToolkit.Maui/Alerts/Snackbar/SnackbarOptions.shared.cs b/src/CommunityToolkit.Maui/Alerts/Snackbar/SnackbarOptions.shared.cs
new file mode 100644
index 000000000..adb188338
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Alerts/Snackbar/SnackbarOptions.shared.cs
@@ -0,0 +1,46 @@
+using Microsoft.Maui;
+using Microsoft.Maui.Graphics;
+
+namespace CommunityToolkit.Maui.Alerts.Snackbar;
+
+///
+/// Snackbar visual options
+///
+public class SnackbarOptions : ITextStyle
+{
+ ///
+ /// Snackbar message character spacing
+ ///
+ public double CharacterSpacing { get; set; } = 0.0d;
+
+ ///
+ /// Snackbar message font
+ ///
+ public Font Font { get; set; } = Font.SystemFontOfSize(14);
+
+ ///
+ /// Snackbar message text color
+ ///
+ public Color TextColor { get; set; } = Colors.Black;
+
+ ///
+ /// Snackbar button font
+ ///
+ public Font ActionButtonFont { get; set; } = Font.SystemFontOfSize(14);
+
+ ///
+ /// Snackbar action button text color
+ ///
+ public Color ActionButtonTextColor { get; set; } = Colors.Black;
+
+ ///
+ /// Snackbar background color
+ ///
+ public Color BackgroundColor { get; set; } = Colors.LightGray;
+
+ ///
+ /// Snackbar corner radius
+ ///
+ public CornerRadius CornerRadius { get; set; } = new CornerRadius(4, 4, 4, 4);
+
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Alerts/Toast/Toast.ios.macos.cs b/src/CommunityToolkit.Maui/Alerts/Toast/Toast.ios.macos.cs
new file mode 100644
index 000000000..4b04736be
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Alerts/Toast/Toast.ios.macos.cs
@@ -0,0 +1,96 @@
+using System;
+using CommunityToolkit.Maui.Views.Popup;
+using CoreGraphics;
+using CoreText;
+using Foundation;
+using UIKit;
+
+namespace CommunityToolkit.Maui.Alerts.Toast;
+
+class ToastView : Popup
+{
+ public const double DefaultPadding = 10;
+
+ readonly PaddedLabel _messageLabel;
+
+ public ToastView(
+ string message,
+ UIColor backgroundColor,
+ CGRect cornerRadius,
+ UIColor textColor,
+ UIFont font,
+ double characterSpacing,
+ double padding = DefaultPadding)
+ {
+ _messageLabel = new PaddedLabel(padding, padding, padding, padding)
+ {
+ Lines = 10
+ };
+
+ Message = message;
+ TextColor = textColor;
+ Font = font;
+ CharacterSpacing = characterSpacing;
+ PopupView.VisualOptions.BackgroundColor = backgroundColor;
+ PopupView.VisualOptions.CornerRadius = cornerRadius;
+ PopupView.AddChild(_messageLabel);
+ }
+
+ public string? Message
+ {
+ get => _messageLabel.Text;
+ private init => _messageLabel.Text = value;
+ }
+
+ public UIColor? TextColor
+ {
+ get => _messageLabel.TextColor;
+ private init => _messageLabel.TextColor = value;
+ }
+
+ public UIFont Font
+ {
+ get => _messageLabel.Font;
+ private init => _messageLabel.Font = value;
+ }
+
+ public double CharacterSpacing
+ {
+ init
+ {
+ var em = GetEmFromPx(Font.PointSize, value);
+ _messageLabel.AttributedText = new NSAttributedString(Message, new CTStringAttributes() { KerningAdjustment = (float)em });
+ }
+ }
+
+ static nfloat GetEmFromPx(nfloat defaultFontSize, double currentValue) => 100 * (nfloat)currentValue / defaultFontSize;
+
+ class PaddedLabel : UILabel
+ {
+ public PaddedLabel(double left, double top, double right, double bottom)
+ {
+ Left = (nfloat)left;
+ Top = (nfloat)top;
+ Right = (nfloat)right;
+ Bottom = (nfloat)bottom;
+ }
+
+ public nfloat Left { get; }
+
+ public nfloat Top { get; }
+
+ public nfloat Right { get; }
+
+ public nfloat Bottom { get; }
+
+ public override CGSize IntrinsicContentSize => new (
+ base.IntrinsicContentSize.Width + Left + Right,
+ base.IntrinsicContentSize.Height + Top + Bottom);
+
+ public override void DrawText(CGRect rect)
+ {
+ var insets = new UIEdgeInsets(Top, Left, Bottom, Right);
+ base.DrawText(insets.InsetRect(rect));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj
index 4215e2a4a..f6b5c4f44 100644
--- a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj
+++ b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj
@@ -43,7 +43,7 @@
-
+
@@ -69,11 +69,11 @@
+
-
diff --git a/src/CommunityToolkit.Maui/Extensions/UIViewExtensions.ios.macos.cs b/src/CommunityToolkit.Maui/Extensions/UIViewExtensions.ios.macos.cs
new file mode 100644
index 000000000..cbc40724b
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Extensions/UIViewExtensions.ios.macos.cs
@@ -0,0 +1,89 @@
+using UIKit;
+
+namespace CommunityToolkit.Maui.Extensions.Internals;
+
+///
+/// UIView extensions
+///
+public static class UIViewExtensions
+{
+ ///
+ /// Safe bottom edge of the guide
+ ///
+ public static NSLayoutYAxisAnchor SafeBottomAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.BottomAnchor
+ : view.BottomAnchor;
+
+ ///
+ /// Safe horizontal center of the guide
+ ///
+ public static NSLayoutXAxisAnchor SafeCenterXAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.CenterXAnchor
+ : view.CenterXAnchor;
+
+ ///
+ /// Safe vertical center of the guide
+ ///
+ public static NSLayoutYAxisAnchor SafeCenterYAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.CenterYAnchor
+ : view.CenterYAnchor;
+
+ ///
+ /// Safe vertical extent of the guide
+ ///
+ public static NSLayoutDimension SafeHeightAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.HeightAnchor
+ : view.HeightAnchor;
+
+ ///
+ /// Safe leading edge of the guide
+ ///
+ public static NSLayoutXAxisAnchor SafeLeadingAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.LeadingAnchor
+ : view.LeadingAnchor;
+
+ ///
+ /// Safe left edge of the guide
+ ///
+ public static NSLayoutXAxisAnchor SafeLeftAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.LeftAnchor
+ : view.LeftAnchor;
+
+ ///
+ /// Safe right edge of the guide
+ ///
+ public static NSLayoutXAxisAnchor SafeRightAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.RightAnchor
+ : view.RightAnchor;
+
+ ///
+ /// Safe top edge of the guide
+ ///
+ public static NSLayoutYAxisAnchor SafeTopAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.TopAnchor
+ : view.TopAnchor;
+
+ ///
+ /// Safe trailing edge of the guide
+ ///
+ public static NSLayoutXAxisAnchor SafeTrailingAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.TrailingAnchor
+ : view.TrailingAnchor;
+
+ ///
+ /// Safe width edge of the guide
+ ///
+ public static NSLayoutDimension SafeWidthAnchor(this UIView view) =>
+ UIDevice.CurrentDevice.CheckSystemVersion(11, 0)
+ ? view.SafeAreaLayoutGuide.WidthAnchor
+ : view.WidthAnchor;
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Views/Popup/Popup.ios.macos.cs b/src/CommunityToolkit.Maui/Views/Popup/Popup.ios.macos.cs
new file mode 100644
index 000000000..80130760e
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Views/Popup/Popup.ios.macos.cs
@@ -0,0 +1,49 @@
+using System;
+using CoreGraphics;
+using Foundation;
+using UIKit;
+
+namespace CommunityToolkit.Maui.Views.Popup;
+
+class Popup
+{
+ public Popup()
+ {
+ PopupView = new PopupView();
+
+ PopupView.ParentView.AddSubview(PopupView);
+ PopupView.ParentView.BringSubviewToFront(PopupView);
+ }
+
+ NSTimer? timer;
+
+ public TimeSpan Duration { get; set; }
+
+ public UIView? Anchor { get; set; }
+
+ protected PopupView PopupView { get; }
+
+ public void Dismiss()
+ {
+ if (timer != null)
+ {
+ timer.Invalidate();
+ timer.Dispose();
+ timer = null;
+ }
+
+ PopupView.Dismiss();
+ }
+
+ public void Show()
+ {
+ PopupView.AnchorView = Anchor;
+
+ PopupView.Setup();
+
+ timer = NSTimer.CreateScheduledTimer(Duration, t =>
+ {
+ Dismiss();
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Maui/Views/Popup/PopupView.ios.macos.cs b/src/CommunityToolkit.Maui/Views/Popup/PopupView.ios.macos.cs
new file mode 100644
index 000000000..69f416feb
--- /dev/null
+++ b/src/CommunityToolkit.Maui/Views/Popup/PopupView.ios.macos.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using CommunityToolkit.Maui.Extensions.Internals;
+using CoreAnimation;
+using CoreGraphics;
+using CoreGraphics;
+using UIKit;
+using UIKit;
+
+namespace CommunityToolkit.Maui.Views.Popup;
+
+class PopupViewVisualOptions
+{
+ public CGRect CornerRadius { get; set; }
+
+ public UIColor BackgroundColor { get; set; } = UIColor.Gray;
+}
+
+class PopupView : UIView
+{
+ readonly List _children = Array.Empty().ToList();
+
+ public UIView ParentView => UIApplication.SharedApplication.Windows.First(x => x.IsKeyWindow);
+
+ public IReadOnlyList Children => _children;
+
+ public UIView? AnchorView { get; set; }
+
+ public PopupViewVisualOptions VisualOptions { get; } = new();
+
+ protected UIStackView? Container { get; set; }
+
+ public void Dismiss() => RemoveFromSuperview();
+
+ public void AddChild(UIView child) => _children.Add(child);
+
+ public void Setup()
+ {
+ Initialize();
+ ConstraintInParent();
+ }
+
+ void ConstraintInParent()
+ {
+ _ = ParentView ?? throw new InvalidOperationException($"{nameof(PopupView)}.{nameof(Initialize)} not called");
+ _ = Container ?? throw new InvalidOperationException($"{nameof(PopupView)}.{nameof(Initialize)} not called");
+
+ const int defaultSpacing = 10;
+ if (AnchorView is null)
+ {
+ this.SafeBottomAnchor().ConstraintEqualTo(ParentView.SafeBottomAnchor(), -defaultSpacing).Active = true;
+ this.SafeTopAnchor().ConstraintGreaterThanOrEqualTo(ParentView.SafeTopAnchor(), defaultSpacing).Active = true;
+ }
+ else
+ {
+ this.SafeBottomAnchor().ConstraintEqualTo(AnchorView.SafeBottomAnchor(), -defaultSpacing).Active = true;
+ }
+
+ this.SafeLeadingAnchor().ConstraintGreaterThanOrEqualTo(ParentView.SafeLeadingAnchor(), defaultSpacing).Active = true;
+ this.SafeTrailingAnchor().ConstraintLessThanOrEqualTo(ParentView.SafeTrailingAnchor(), -defaultSpacing).Active = true;
+ this.SafeCenterXAnchor().ConstraintEqualTo(ParentView.SafeCenterXAnchor()).Active = true;
+
+ Container.SafeLeadingAnchor().ConstraintEqualTo(this.SafeLeadingAnchor(), defaultSpacing).Active = true;
+ Container.SafeTrailingAnchor().ConstraintEqualTo(this.SafeTrailingAnchor(), -defaultSpacing).Active = true;
+ Container.SafeBottomAnchor().ConstraintEqualTo(this.SafeBottomAnchor(), -defaultSpacing).Active = true;
+ Container.SafeTopAnchor().ConstraintEqualTo(this.SafeTopAnchor(), defaultSpacing).Active = true;
+ }
+
+ [MemberNotNull(nameof(Container))]
+ void Initialize()
+ {
+ Container = new RoundedStackView(
+ VisualOptions.CornerRadius.X,
+ VisualOptions.CornerRadius.Y,
+ VisualOptions.CornerRadius.Width,
+ VisualOptions.CornerRadius.Height);
+
+ AddSubview(Container);
+
+ Container.Axis = UILayoutConstraintAxis.Horizontal;
+ Container.TranslatesAutoresizingMaskIntoConstraints = false;
+ Container.BackgroundColor = VisualOptions.BackgroundColor;
+
+ TranslatesAutoresizingMaskIntoConstraints = false;
+
+ foreach (var view in Children)
+ {
+ Container.AddArrangedSubview(view);
+ }
+ }
+
+ class RoundedStackView : UIStackView
+ {
+ public nfloat Left { get; }
+
+ public nfloat Top { get; }
+
+ public nfloat Right { get; }
+
+ public nfloat Bottom { get; }
+
+ public RoundedStackView(nfloat left, nfloat top, nfloat right, nfloat bottom)
+ {
+ Left = left;
+ Top = top;
+ Right = right;
+ Bottom = bottom;
+ }
+
+ public override void Draw(CGRect rect)
+ {
+ ClipsToBounds = true;
+ var path = GetRoundedPath(rect, Left, Top, Right, Bottom);
+ var maskLayer = new CAShapeLayer { Frame = rect, Path = path };
+ Layer.Mask = maskLayer;
+ Layer.MasksToBounds = true;
+ }
+
+ static CGPath? GetRoundedPath(CGRect rect, nfloat left, nfloat top, nfloat right, nfloat bottom)
+ {
+ var path = new UIBezierPath();
+ path.MoveTo(new CGPoint(rect.Width - right, rect.Y));
+
+ path.AddArc(new CGPoint((float)rect.X + rect.Width - right, (float)rect.Y + right), (nfloat)right, (float)(Math.PI * 1.5), (float)Math.PI * 2, true);
+ path.AddLineTo(new CGPoint(rect.Width, rect.Height - bottom));
+
+ path.AddArc(new CGPoint((float)rect.X + rect.Width - bottom, (float)rect.Y + rect.Height - bottom), (nfloat)bottom, 0, (float)(Math.PI * .5), true);
+ path.AddLineTo(new CGPoint(left, rect.Height));
+
+ path.AddArc(new CGPoint((float)rect.X + left, (float)rect.Y + rect.Height - left), (nfloat)left, (float)(Math.PI * .5), (float)Math.PI, true);
+ path.AddLineTo(new CGPoint(rect.X, top));
+
+ path.AddArc(new CGPoint((float)rect.X + top, (float)rect.Y + top), (nfloat)top, (float)Math.PI, (float)(Math.PI * 1.5), true);
+
+ path.ClosePath();
+
+ return path.CGPath;
+ }
+ }
+}
\ No newline at end of file