diff --git a/doc/controls/ZoomContentControl.md b/doc/controls/ZoomContentControl.md
new file mode 100644
index 000000000..b8c95813e
--- /dev/null
+++ b/doc/controls/ZoomContentControl.md
@@ -0,0 +1,68 @@
+---
+uid: Toolkit.Controls.ZoomContentControl
+---
+
+# ZoomContentControl
+
+> [!TIP]
+> This guide covers details for the `ZoomContentControl`. If you are just getting started with the Uno Toolkit UI Library, please see our [general getting started](../getting-started.md) page to make sure you have the correct setup in place.
+
+## Summary
+
+`ZoomContentControl` allows you to display content that can be zoomed in and out, as well as panned. It is especially useful for scenarios such as viewing large images, maps, or documents where users need control over zoom levels and panning.
+
+### C\#
+
+```csharp
+public partial class ZoomContentControl : Control
+```
+
+### XAML
+
+```xml
+xmlns:utu="using:Uno.Toolkit.UI"
+...
+
+
+
+
+
+
+```
+
+### Inheritance
+
+`Object` → `DependencyObject` → `UIElement` → `FrameworkElement` → `Control` → `ContentControl` → `ZoomContentControl`
+
+### Constructors
+
+| Constructor | Description |
+| ---------------------- | ------------------------------------------------------------- |
+| `ZoomContentControl()` | Initializes a new instance of the `ZoomContentControl` class. |
+
+### Properties
+
+| Property | Type | Description |
+| ------------------ | ----------- | ------------------------------------------------------------------------------------------------------ |
+| `ZoomLevel` | `double` | Gets or sets the current zoom level for the content. |
+| `MinZoomLevel` | `double` | Gets or sets the minimum zoom level allowed for the content. |
+| `MaxZoomLevel` | `double` | Gets or sets the maximum zoom level allowed for the content. |
+| `IsZoomAllowed` | `bool` | Gets or sets a value indicating whether zooming is allowed. |
+| `ScaleWheelRatio` | `double` | Gets or sets the ratio for scaling zoom level when using a mouse wheel. |
+| `PanWheelRatio` | `double` | Gets or sets the ratio for panning when using a mouse wheel. |
+| `IsPanAllowed` | `bool` | Gets or sets a value indicating whether panning is allowed. |
+| `IsActive` | `bool` | Gets or sets a value indicating whether the control is active. |
+| `AutoFitToCanvas` | `bool` | Determines if the content should automatically fit into the available canvas when the control resizes. |
+| `AdditionalMargin` | `Thickness` | Gets or sets additional margins around the content. |
+
+### Methods
+
+| Method | Return Type | Description |
+| ----------------- | ----------- | ----------------------------------------------------------------------------------------- |
+| `FitToCanvas()` | `void` | Adjust the zoom level so that the content fits within the available space. |
+| `ResetViewport()` | `void` | Resets the zoom level and panning offset to their default values and centers the content. |
diff --git a/doc/toc.yml b/doc/toc.yml
index 063595b2c..7954e347d 100644
--- a/doc/toc.yml
+++ b/doc/toc.yml
@@ -51,6 +51,8 @@
href: controls/ShadowContainer.md
- name: TabBar and TabBarItem
href: controls/TabBarAndTabBarItem.md
+ - name: ZoomContentControl
+ href: controls/ZoomContentControl.md
# ***************** Reference\Helpers *******************
- name: Helpers
items:
diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ZoomContentControlSamplePage.xaml b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ZoomContentControlSamplePage.xaml
new file mode 100644
index 000000000..b0bdca11c
--- /dev/null
+++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ZoomContentControlSamplePage.xaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ZoomContentControlSamplePage.xaml.cs b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ZoomContentControlSamplePage.xaml.cs
new file mode 100644
index 000000000..5b66d1313
--- /dev/null
+++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ZoomContentControlSamplePage.xaml.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Uno.Toolkit.Samples.Entities;
+using Uno.Toolkit.UI;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+
+#if IS_WINUI
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+#else
+using Windows.UI;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+using Windows.UI.Xaml.Data;
+using Windows.UI.Xaml.Input;
+using Windows.UI.Xaml.Media;
+using Windows.UI.Xaml.Navigation;
+#endif
+
+
+namespace Uno.Toolkit.Samples.Content.Controls;
+
+[SamplePage(SampleCategory.Controls, "ZoomContentControl")]
+public sealed partial class ZoomContentControlSamplePage : Page
+{
+ private ZoomContentControl zoomControl;
+
+ public ZoomContentControlSamplePage()
+ {
+ this.InitializeComponent();
+ this.Loaded += (s, e) => SetUpOptions();
+ }
+
+ private void SetUpOptions()
+ {
+ zoomControl = SamplePageLayout.GetSampleChild(Design.Agnostic, "ZoomContent");
+ var zoomInButton = SamplePageLayout.GetSampleChild(Design.Agnostic, "ZoomInButton");
+ var zoomOutButton = SamplePageLayout.GetSampleChild(Design.Agnostic, "ZoomOutButton");
+ var resetButton = SamplePageLayout.GetSampleChild(Design.Agnostic, "ResetButton");
+
+ zoomInButton.Click += OnZoomInClick;
+ zoomOutButton.Click += OnZoomOutClick;
+ resetButton.Click += OnResetClick;
+ }
+
+ private void OnZoomInClick(object sender, RoutedEventArgs e)
+ {
+ if (zoomControl.ZoomLevel < zoomControl.MaxZoomLevel)
+ {
+ zoomControl.ZoomLevel += 0.2;
+ }
+ }
+
+ private void OnZoomOutClick(object sender, RoutedEventArgs e)
+ {
+ if (zoomControl.ZoomLevel > zoomControl.MinZoomLevel)
+ {
+ zoomControl.ZoomLevel -= 0.2;
+ }
+ }
+
+ private void OnResetClick(object sender, RoutedEventArgs e)
+ {
+ zoomControl.ResetViewport();
+ }
+}
diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems
index f2548df04..ef1b13ffd 100644
--- a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems
+++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Uno.Toolkit.Samples.Shared.projitems
@@ -74,6 +74,9 @@
VisualStateManagerExtensionsSamplePage.xaml
+
+ ZoomContentControlSamplePage.xaml
+
BindingExtensionsSamplePage.xaml
@@ -317,6 +320,10 @@
Designer
MSBuild:Compile
+
+ Designer
+ MSBuild:Compile
+
Designer
MSBuild:Compile
diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ZoomContentControlTest.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ZoomContentControlTest.cs
new file mode 100644
index 000000000..775cc6700
--- /dev/null
+++ b/src/Uno.Toolkit.RuntimeTests/Tests/ZoomContentControlTest.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Uno.Toolkit.RuntimeTests.Helpers;
+using Uno.Toolkit.UI;
+using Windows.Foundation;
+using FluentAssertions;
+using Uno.UI.RuntimeTests;
+using Uno.UI.Extensions;
+using Uno.Toolkit.RuntimeTests.Tests;
+
+#if IS_WINUI
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+#else
+using Windows.UI;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+using Windows.UI.Xaml.Media;
+using Windows.UI.Xaml.Media.Animation;
+#endif
+
+namespace Uno.Toolkit.RuntimeTests.Tests
+{
+#if false
+ [TestClass]
+ [RunsOnUIThread]
+ internal class ZoomContentControlTest
+ {
+ [TestMethod]
+ public async Task When_ZoomIn_ShouldIncreaseZoomLevel()
+ {
+ var SUT = new ZoomContentControl()
+ {
+ Width = 400,
+ Height = 300,
+ MinZoomLevel = 1.0,
+ MaxZoomLevel = 5.0,
+ ZoomLevel = 1.5,
+ IsZoomAllowed = true,
+ };
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+
+ // the control will set an appropriate zoom level on load
+ // based on available size & content size
+ // so we need to force that to a known value here.
+ SUT.ZoomLevel = 1.5;
+ SUT.ZoomLevel.Should().Be(1.5);
+
+ SUT.ZoomLevel += 0.5;
+ SUT.ZoomLevel.Should().Be(2.0);
+
+ // should be coerce back to MaxZoomLevel of 5
+ SUT.ZoomLevel = 6.0;
+ SUT.ZoomLevel.Should().Be(5.0);
+ }
+
+ [TestMethod]
+ public async Task When_ZoomOut_ShouldDecreaseZoomLevel()
+ {
+ var SUT = new ZoomContentControl()
+ {
+ Width = 400,
+ Height = 300,
+ MinZoomLevel = 1.0,
+ MaxZoomLevel = 5.0,
+ ZoomLevel = 3.0,
+ IsZoomAllowed = true,
+ };
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+
+ // the control will set an appropriate zoom level on load
+ // based on available size & content size
+ // so we need to force that to a known value here.
+ SUT.ZoomLevel = 3.0;
+ SUT.ZoomLevel.Should().Be(3.0);
+
+ SUT.ZoomLevel -= 0.5;
+ SUT.ZoomLevel.Should().Be(2.5);
+
+ // should be coerce back to MinZoomLevel of 1
+ SUT.ZoomLevel = 0.5;
+ SUT.ZoomLevel.Should().Be(1.0);
+ }
+
+ [TestMethod]
+ public async Task When_Reset_ShouldResetZoomAndOffsets()
+ {
+ var SUT = new ZoomContentControl()
+ {
+ Width = 400,
+ Height = 300,
+ ZoomLevel = 2.0,
+ HorizontalOffset = 50,
+ VerticalOffset = 50,
+ IsZoomAllowed = true,
+ };
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+
+ // Perform a reset
+ SUT.ResetZoom();
+ SUT.ZoomLevel.Should().Be(1.0); //ZoomLevel resets to 1.0
+ SUT.HorizontalOffset.Should().Be(0); //HorizontalOffset resets to 0
+ SUT.VerticalOffset.Should().Be(0); //VerticalOffset resets to 0
+ }
+
+ [TestMethod]
+ public async Task When_ContentBounds_ShouldHideScrollBars()
+ {
+ var SUT = new ZoomContentControl()
+ {
+ Width = 400,
+ Height = 300,
+ ZoomLevel = 1.0,
+ IsZoomAllowed = true,
+ Content = new Border
+ {
+ Width = 400 - 20, // the actual height/width is 12
+ Height = 300 - 20,
+ Background = new SolidColorBrush(Colors.Blue),
+ },
+ };
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+
+ //Expect no scrollbars when content fits within the bounds
+ SUT.IsHorizontalScrollBarVisible.Should().BeFalse();
+ SUT.IsVerticalScrollBarVisible.Should().BeFalse();
+
+ //Zoom in to make content larger and display scrollbars
+ SUT.ZoomLevel = 2.0;
+ SUT.IsHorizontalScrollBarVisible.Should().BeTrue();
+ SUT.IsVerticalScrollBarVisible.Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task When_VerticalOffset_ShouldUpdateCorrectly()
+ {
+ var SUT = new ZoomContentControl()
+ {
+ Width = 400,
+ Height = 300,
+ VerticalOffset = 50,
+ IsPanAllowed = true,
+ };
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+
+ // Set vertical offset
+ SUT.VerticalOffset = 100;
+ SUT.VerticalOffset.Should().Be(100);
+
+ // Verify the content is scrolled correctly
+ var presenter = SUT.FindFirstDescendant("PART_Presenter");
+ var translation = (presenter?.RenderTransform as TransformGroup)?.Children[1] as TranslateTransform
+ ?? throw new Exception("Failed to find PART_Presenter's TranslateTransform");
+
+ translation.Y.Should().Be(100); // Verify that the content's Y translation is in sync with the vertical offset
+ }
+
+ [TestMethod]
+ public async Task When_VerticalOffset_ExceedsLimits_ShouldShowScrollBars()
+ {
+ var SUT = new ZoomContentControl()
+ {
+ Width = 400,
+ Height = 300,
+ VerticalOffset = 0,
+ IsPanAllowed = true,
+ IsVerticalScrollBarVisible = true,
+ Content = new Border
+ {
+ Width = 400 - 20, // Smaller content width
+ Height = 500, // Larger content height
+ Background = new SolidColorBrush(Colors.Blue),
+ }
+ };
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+
+ // Simulate setting a large VerticalOffset
+ SUT.VerticalOffset = 200;
+ SUT.VerticalOffset.Should().Be(200);
+
+ // Ensure scrollbar is shown when the content exceeds the bounds
+ SUT.IsVerticalScrollBarVisible.Should().BeTrue();
+ }
+
+ [TestMethod]
+ public async Task When_Pan_ShouldUpdateOffsets()
+ {
+ var SUT = new ZoomContentControl()
+ {
+ Width = 400,
+ Height = 300,
+ ZoomLevel = 1.0,
+ IsPanAllowed = true,
+ HorizontalOffset = 0,
+ VerticalOffset = 0,
+ };
+
+ await UnitTestUIContentHelperEx.SetContentAndWait(SUT);
+
+ var presenter = SUT.GetFirstDescendant(x => x.Name == "PART_Presenter");
+ var translation = (presenter?.RenderTransform as TransformGroup)?.Children[1] as TranslateTransform
+ ?? throw new Exception("Failed to find PART_Presenter's TranslateTransform");
+
+ // Simulate panning
+ SUT.HorizontalOffset = 50;
+ SUT.VerticalOffset = 50;
+
+ // Verify that the content's translate transform has been updated to reflect the new offsets
+ translation.X.Should().Be(50);
+ translation.Y.Should().Be(50);
+ }
+ }
+#endif
+}
diff --git a/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.Properties.cs b/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.Properties.cs
new file mode 100644
index 000000000..2e39473db
--- /dev/null
+++ b/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.Properties.cs
@@ -0,0 +1,350 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Numerics;
+using Uno.Disposables;
+using Uno.Extensions.Specialized;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+using System.Threading.Tasks;
+
+#if IS_WINUI
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Microsoft.UI.Input;
+#else
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+using Windows.UI.Xaml.Data;
+using Windows.UI.Xaml.Input;
+using Windows.UI.Xaml.Media;
+using Windows.UI.Xaml.Navigation;
+using Windows.UI.Input;
+using Windows.Devices.Input;
+#endif
+
+namespace Uno.Toolkit.UI;
+
+public partial class ZoomContentControl
+{
+ #region DependencyProperty: [Private] HorizontalScrollValue
+
+ private static DependencyProperty HorizontalScrollValueProperty { get; } = DependencyProperty.Register(
+ nameof(HorizontalScrollValue),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double), OnHorizontalScrollValueChanged));
+
+ private double HorizontalScrollValue
+ {
+ get => (double)GetValue(HorizontalScrollValueProperty);
+ set => SetValue(HorizontalScrollValueProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: [Private] HorizontalMinScroll
+
+ /// Identifies the HorizontalMinScroll dependency property.
+ private static DependencyProperty HorizontalMinScrollProperty { get; } = DependencyProperty.Register(
+ nameof(HorizontalMinScroll),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double)));
+
+ /// Gets or sets the minimum horizontal scroll limit.
+ private double HorizontalMinScroll
+ {
+ get => (double)GetValue(HorizontalMinScrollProperty);
+ set => SetValue(HorizontalMinScrollProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: [Private] HorizontalMaxScroll
+
+ /// Identifies the HorizontalMaxScroll dependency property.
+ private static DependencyProperty HorizontalMaxScrollProperty { get; } = DependencyProperty.Register(
+ nameof(HorizontalMaxScroll),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double)));
+
+ /// Gets or sets the maximum horizontal scroll limit.
+ private double HorizontalMaxScroll
+ {
+ get => (double)GetValue(HorizontalMaxScrollProperty);
+ set => SetValue(HorizontalMaxScrollProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: [Private] HorizontalZoomCenter
+
+ /// Identifies the HorizontalZoomCenter dependency property.
+ private static DependencyProperty HorizontalZoomCenterProperty { get; } = DependencyProperty.Register(
+ nameof(HorizontalZoomCenter),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double)));
+
+ /// Gets or sets the horizontal center point for zooming.
+ private double HorizontalZoomCenter
+ {
+ get => (double)GetValue(HorizontalZoomCenterProperty);
+ set => SetValue(HorizontalZoomCenterProperty, value);
+ }
+
+ #endregion
+
+ #region DependencyProperty: [Private] VerticalScrollValue
+
+ private static DependencyProperty VerticalScrollValueProperty { get; } = DependencyProperty.Register(
+ nameof(VerticalScrollValue),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double), OnVerticalScrollValueChanged));
+
+ private double VerticalScrollValue
+ {
+ get => (double)GetValue(VerticalScrollValueProperty);
+ set => SetValue(VerticalScrollValueProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: [Private] VerticalMaxScroll
+
+ /// Identifies the VerticalMaxScroll dependency property.
+ private static DependencyProperty VerticalMaxScrollProperty { get; } = DependencyProperty.Register(
+ nameof(VerticalMaxScroll),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double)));
+
+ /// Gets or sets the maximum vertical scroll limit.
+ private double VerticalMaxScroll
+ {
+ get => (double)GetValue(VerticalMaxScrollProperty);
+ set => SetValue(VerticalMaxScrollProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: [Private] VerticalMinScroll
+
+ /// Identifies the VerticalMinScroll dependency property.
+ private static DependencyProperty VerticalMinScrollProperty { get; } = DependencyProperty.Register(
+ nameof(VerticalMinScroll),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double)));
+
+ /// Gets or sets the minimum vertical scroll limit.
+ private double VerticalMinScroll
+ {
+ get => (double)GetValue(VerticalMinScrollProperty);
+ set => SetValue(VerticalMinScrollProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: [Private] VerticalZoomCenter
+
+ /// Identifies the VerticalZoomCenter dependency property.
+ private static DependencyProperty VerticalZoomCenterProperty { get; } = DependencyProperty.Register(
+ nameof(VerticalZoomCenter),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(double)));
+
+ /// Gets or sets the vertical center point for zooming.
+ private double VerticalZoomCenter
+ {
+ get => (double)GetValue(VerticalZoomCenterProperty);
+ set => SetValue(VerticalZoomCenterProperty, value);
+ }
+
+ #endregion
+
+ #region DependencyProperty: ZoomLevel
+
+ /// Identifies the ZoomLevel dependency property.
+ public static DependencyProperty ZoomLevelProperty { get; } = DependencyProperty.Register(
+ nameof(ZoomLevel),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(1d, OnZoomLevelChanged));
+
+ /// Gets or sets the current zoom level.
+ public double ZoomLevel
+ {
+ get => (double)GetValue(ZoomLevelProperty);
+ set => SetValue(ZoomLevelProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: MinZoomLevel
+
+ /// Identifies the MinZoomLevel dependency property.
+ public static DependencyProperty MinZoomLevelProperty { get; } = DependencyProperty.Register(
+ nameof(MinZoomLevel),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(1d, OnMinZoomLevelChanged));
+
+ /// Gets or sets the minimum zoom level allowed.
+ public double MinZoomLevel
+ {
+ get => (double)GetValue(MinZoomLevelProperty);
+ set => SetValue(MinZoomLevelProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: MaxZoomLevel
+
+ /// Identifies the MaxZoomLevel dependency property.
+ public static DependencyProperty MaxZoomLevelProperty { get; } = DependencyProperty.Register(
+ nameof(MaxZoomLevel),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(10d, OnMaxZoomLevelChanged));
+
+ /// Gets or sets the maximum zoom level allowed.
+ public double MaxZoomLevel
+ {
+ get => (double)GetValue(MaxZoomLevelProperty);
+ set => SetValue(MaxZoomLevelProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: IsZoomAllowed
+
+ /// Identifies the IsZoomAllowed dependency property.
+ public static DependencyProperty IsZoomAllowedProperty { get; } = DependencyProperty.Register(
+ nameof(IsZoomAllowed),
+ typeof(bool),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(true));
+
+ /// Gets or sets a value indicating whether zooming is allowed.
+ public bool IsZoomAllowed
+ {
+ get => (bool)GetValue(IsZoomAllowedProperty);
+ set => SetValue(IsZoomAllowedProperty, value);
+ }
+
+ #endregion
+
+ #region DependencyProperty: ScaleWheelRatio
+
+ /// Identifies the ScaleWheelRatio dependency property.
+ public static DependencyProperty ScaleWheelRatioProperty { get; } = DependencyProperty.Register(
+ nameof(ScaleWheelRatio),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(0.0006d));
+
+ /// Gets or sets the ratio used for scaling the zoom level with the mouse wheel.
+ public double ScaleWheelRatio
+ {
+ get => (double)GetValue(ScaleWheelRatioProperty);
+ set => SetValue(ScaleWheelRatioProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: PanWheelRatio
+
+ /// Identifies the PanWheelRatio dependency property.
+ public static DependencyProperty PanWheelRatioProperty { get; } = DependencyProperty.Register(
+ nameof(PanWheelRatio),
+ typeof(double),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(0.25d));
+
+ /// Gets or sets the ratio used for panning with the mouse wheel.
+ public double PanWheelRatio
+ {
+ get => (double)GetValue(PanWheelRatioProperty);
+ set => SetValue(PanWheelRatioProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: IsPanAllowed
+
+ /// Identifies the IsPanAllowed dependency property.
+ public static DependencyProperty IsPanAllowedProperty { get; } = DependencyProperty.Register(
+ nameof(IsPanAllowed),
+ typeof(bool),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(true));
+
+ /// Gets or sets a value indicating whether panning is allowed.
+ public bool IsPanAllowed
+ {
+ get => (bool)GetValue(IsPanAllowedProperty);
+ set => SetValue(IsPanAllowedProperty, value);
+ }
+
+ #endregion
+
+ #region DependencyProperty: IsActive
+
+ /// Identifies the IsActive dependency property.
+ public static DependencyProperty IsActiveProperty { get; } = DependencyProperty.Register(
+ nameof(IsActive),
+ typeof(bool),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(true, OnIsActiveChanged));
+
+ /// Gets or sets a value indicating whether the control is active.
+ public bool IsActive
+ {
+ get => (bool)GetValue(IsActiveProperty);
+ set => SetValue(IsActiveProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: AutoFitToCanvas
+
+ public static DependencyProperty AutoFitToCanvasProperty { get; } = DependencyProperty.Register(
+ nameof(AutoFitToCanvas),
+ typeof(bool),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(default(bool)));
+
+ public bool AutoFitToCanvas
+ {
+ get => (bool)GetValue(AutoFitToCanvasProperty);
+ set => SetValue(AutoFitToCanvasProperty, value);
+ }
+
+ #endregion
+ #region DependencyProperty: AdditionalMargin
+
+ /// Identifies the AdditionalMargin dependency property.
+ public static DependencyProperty AdditionalMarginProperty { get; } = DependencyProperty.Register(
+ nameof(AdditionalMargin),
+ typeof(Thickness),
+ typeof(ZoomContentControl),
+ new PropertyMetadata(new Thickness(0), OnAdditionalMarginChanged));
+
+ /// Gets or sets additional margins around the content.
+ public Thickness AdditionalMargin
+ {
+ get => (Thickness)GetValue(AdditionalMarginProperty);
+ set => SetValue(AdditionalMarginProperty, value);
+ }
+
+ #endregion
+
+ private static void OnHorizontalScrollValueChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => ((ZoomContentControl)sender).OnHorizontalScrollValueChanged();
+ private static void OnVerticalScrollValueChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => ((ZoomContentControl)sender).OnVerticalScrollValueChanged();
+ private static void OnZoomLevelChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => ((ZoomContentControl)sender).OnZoomLevelChanged();
+ private static void OnMinZoomLevelChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => ((ZoomContentControl)sender).CoerceZoomLevel();
+ private static void OnMaxZoomLevelChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => ((ZoomContentControl)sender).CoerceZoomLevel();
+ private static void OnIsActiveChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => ((ZoomContentControl)sender).IsActiveChanged();
+ private static void OnAdditionalMarginChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) => ((ZoomContentControl)sender).OnAdditionalMarginChanged();
+}
diff --git a/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.cs b/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.cs
new file mode 100644
index 000000000..064627e0b
--- /dev/null
+++ b/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.cs
@@ -0,0 +1,407 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Numerics;
+using Uno.Disposables;
+using Uno.Extensions.Specialized;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+using System.Threading.Tasks;
+
+#if IS_WINUI
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Microsoft.UI.Input;
+#else
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+using Windows.UI.Xaml.Data;
+using Windows.UI.Xaml.Input;
+using Windows.UI.Xaml.Media;
+using Windows.UI.Xaml.Navigation;
+using Windows.UI.Input;
+using Windows.Devices.Input;
+#endif
+
+namespace Uno.Toolkit.UI;
+
+[TemplatePart(Name = TemplateParts.RootGrid, Type = typeof(Grid))]
+[TemplatePart(Name = TemplateParts.ContentGrid, Type = typeof(Grid))]
+[TemplatePart(Name = TemplateParts.ContentPresenter, Type = typeof(ContentPresenter))]
+[TemplatePart(Name = TemplateParts.VerticalScrollBar, Type = typeof(ScrollBar))]
+[TemplatePart(Name = TemplateParts.HorizontalScrollBar, Type = typeof(ScrollBar))]
+[TemplatePart(Name = TemplateParts.TranslateTransform, Type = typeof(TranslateTransform))]
+public partial class ZoomContentControl : ContentControl
+{
+ private static class TemplateParts
+ {
+ public const string RootGrid = "PART_RootGrid";
+ public const string ContentGrid = "PART_ContentGrid";
+ public const string ContentPresenter = "PART_ContentPresenter";
+ public const string HorizontalScrollBar = "PART_ScrollH";
+ public const string VerticalScrollBar = "PART_ScrollV";
+ public const string TranslateTransform = "PART_TranslateTransform";
+ }
+
+ public event EventHandler? RenderedContentUpdated;
+
+ private Grid? _contentGrid;
+ private ContentPresenter? _contentPresenter;
+ private ScrollBar? _scrollV;
+ private ScrollBar? _scrollH;
+ private TranslateTransform? _translation;
+
+ private (uint Id, Point Position, Point ScrollOffset)? _capturedPointerContext;
+ private SerialDisposable _contentSubscriptions = new();
+ private Size _contentSize;
+
+ public ZoomContentControl()
+ {
+ DefaultStyleKey = typeof(ZoomContentControl);
+
+ SizeChanged += OnSizeChanged;
+ PointerPressed += OnPointerPressed;
+ PointerReleased += OnPointerReleased;
+ PointerMoved += OnPointerMoved;
+ PointerWheelChanged += OnPointerWheelChanged;
+ }
+
+ protected override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ T FindTemplatePart(string name) where T : class =>
+ (GetTemplateChild(name) ?? throw new Exception($"Expected template part not found: {name}"))
+ as T ?? throw new Exception($"Expected template part '{name}' to be of type: {typeof(T)}");
+
+ _contentGrid = FindTemplatePart(TemplateParts.ContentGrid);
+ _contentPresenter = FindTemplatePart(TemplateParts.ContentPresenter);
+ _scrollV = FindTemplatePart(TemplateParts.VerticalScrollBar);
+ _scrollH = FindTemplatePart(TemplateParts.HorizontalScrollBar);
+ _translation = FindTemplatePart(TemplateParts.TranslateTransform);
+
+ ResetViewport();
+ }
+
+ protected override void OnContentChanged(object oldContent, object newContent)
+ {
+ _contentSubscriptions.Disposable = null;
+ if (newContent is FrameworkElement { } fe)
+ {
+ fe.Loaded += OnContentLoaded;
+ fe.SizeChanged += OnContentSizeChanged;
+ _contentSubscriptions.Disposable = Disposable.Create(() =>
+ {
+ fe.Loaded -= OnContentLoaded;
+ fe.SizeChanged -= OnContentSizeChanged;
+ });
+ }
+
+ void OnContentLoaded(object sender, RoutedEventArgs e)
+ {
+ if (AutoFitToCanvas)
+ {
+ FitToCanvas();
+ }
+ }
+ void OnContentSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ _contentSize = new Size(fe.ActualWidth, fe.ActualHeight);
+ HorizontalZoomCenter = _contentSize.Width / 2;
+ VerticalZoomCenter = _contentSize.Height / 2;
+
+ UpdateScrollBars();
+ UpdateScrollVisibility();
+ }
+ }
+
+ private async Task RaiseRenderedContentUpdated()
+ {
+ await Task.Yield();
+ RenderedContentUpdated?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnHorizontalScrollValueChanged()
+ {
+ UpdateTranslation();
+ }
+
+ private void OnVerticalScrollValueChanged()
+ {
+ UpdateTranslation();
+ }
+
+ private void OnAdditionalMarginChanged()
+ {
+ _contentPresenter?.ToString();
+ }
+
+ private async void OnZoomLevelChanged()
+ {
+ if (CoerceZoomLevel())
+ {
+ return;
+ }
+
+ UpdateScrollBars();
+ UpdateScrollVisibility();
+ await RaiseRenderedContentUpdated();
+ }
+
+ private void UpdateScrollVisibility()
+ {
+ if (Viewport is { } vp)
+ {
+ ToggleScrollBarVisibility(_scrollH, vp.ActualWidth < ScrollExtentWidth);
+ ToggleScrollBarVisibility(_scrollV, vp.ActualHeight < ScrollExtentHeight);
+ }
+
+ void ToggleScrollBarVisibility(ScrollBar? sb, bool value)
+ {
+ if (sb is null) return;
+
+ // Showing/hiding the ScrollBar(s)could cause the ContentPresenter to move as it re-centers.
+ // This adds unnecessary complexity for the zooming logics as we need to preserve the focal point
+ // under the cursor position or the pinch center point after zooming.
+ // To avoid all that, we just make them permanently there for layout calculation.
+ sb.IsEnabled = value;
+ sb.Opacity = value ? 1 : 0;
+ }
+ }
+
+ private void IsActiveChanged()
+ {
+ if (!IsActive)
+ {
+ ResetOffset();
+ ResetZoom();
+ }
+ if (_scrollH is not null)
+ {
+ _scrollH.Visibility = IsActive ? Visibility.Visible : Visibility.Collapsed;
+ }
+ if (_scrollV is not null)
+ {
+ _scrollV.Visibility = IsActive ? Visibility.Visible : Visibility.Collapsed;
+ }
+ }
+
+ private void UpdateTranslation()
+ {
+ if (_translation is { })
+ {
+ _translation.X = HorizontalScrollValue;
+ _translation.Y = VerticalScrollValue * -1; // Having a -1 here aligned the scroll direction with content translation
+ }
+ }
+
+ private void UpdateScrollBars()
+ {
+ if (Viewport is { } vp)
+ {
+ var scrollableWidth = Math.Max(0, ScrollExtentWidth - vp.ActualWidth);
+ var scrollableHeight = Math.Max(0, ScrollExtentHeight - vp.ActualHeight);
+
+ // since the content is always centered, we need to able to scroll both way equally:
+ // [Content-Content-Content]
+ // [=======[Viewport]======]
+ HorizontalMaxScroll = scrollableWidth / 2;
+ HorizontalMinScroll = -HorizontalMaxScroll;
+ VerticalMaxScroll = scrollableHeight / 2;
+ VerticalMinScroll = -VerticalMaxScroll;
+
+ // update size of thumb
+ if (_scrollH is { }) _scrollH.ViewportSize = vp.ActualWidth;
+ if (_scrollV is { }) _scrollV.ViewportSize = vp.ActualHeight;
+ }
+ }
+
+ private bool CoerceZoomLevel()
+ {
+ var zoomLevel = ZoomLevel;
+ var coercedZoomLevel = Math.Clamp(zoomLevel, MinZoomLevel, MaxZoomLevel);
+ if (coercedZoomLevel != zoomLevel)
+ {
+ ZoomLevel = coercedZoomLevel;
+ return true;
+ }
+
+ return false;
+ }
+
+ private void OnSizeChanged(object sender, SizeChangedEventArgs args)
+ {
+ if (IsLoaded && AutoFitToCanvas)
+ {
+ FitToCanvas();
+ }
+ }
+
+ private void OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ if (!IsAllowedToWork || _translation is null) return;
+ var pointerPoint = e.GetCurrentPoint(this);
+ var pointerProperties = pointerPoint.Properties;
+
+ if (pointerProperties.IsMiddleButtonPressed
+#if IS_WINUI
+ && pointerPoint.PointerDeviceType == PointerDeviceType.Mouse)
+#else
+ && pointerPoint.PointerDevice.PointerDeviceType == PointerDeviceType.Mouse)
+#endif
+ {
+ e.Handled = true;
+
+ var captured = CapturePointer(e.Pointer);
+ if (captured)
+ {
+ _capturedPointerContext = (
+ e.Pointer.PointerId,
+ pointerPoint.Position,
+ ScrollValue
+ );
+ }
+ else
+ {
+ _capturedPointerContext = default;
+ }
+ }
+ }
+
+ private void OnPointerReleased(object sender, PointerRoutedEventArgs e)
+ {
+ ReleasePointerCaptures();
+ _capturedPointerContext = default;
+ }
+
+ private void OnPointerMoved(object sender, PointerRoutedEventArgs e)
+ {
+ if (!IsAllowedToWork ||!IsPanAllowed) return;
+
+ if (_capturedPointerContext is { } context)
+ {
+ var position = e.GetCurrentPoint(this).Position;
+ var delta = context.Position.Subtract(position);
+ delta.X *= -1;
+
+ SetScrollValue(context.ScrollOffset.Add(delta));
+ }
+ }
+
+ private void OnPointerWheelChanged(object sender, PointerRoutedEventArgs e)
+ {
+ if (!IsAllowedToWork) return;
+ if (Viewport is not { } vp) return;
+
+ var p = e.GetCurrentPoint(vp);
+ if (
+#if IS_WINUI
+ p.PointerDeviceType != PointerDeviceType.Mouse
+#else
+ p.PointerDevice.PointerDeviceType != PointerDeviceType.Mouse
+#endif
+ ) return;
+
+ // MouseWheel + Ctrl: Zoom
+ if (e.KeyModifiers.HasFlag(Windows.System.VirtualKeyModifiers.Control))
+ {
+ if (!IsZoomAllowed) return;
+
+ var oldPosition = p.Position
+ .Subtract(vp.ActualSize.ToPoint().DivideBy(2))
+ .Subtract(ScrollValue.MultiplyBy(1, -1));
+ var basePosition = oldPosition.DivideBy(ZoomLevel);
+
+ var newZoom = ZoomLevel * (1 + p.Properties.MouseWheelDelta * ScaleWheelRatio);
+ //var newZoom = ZoomLevel + Math.Sign(p.Properties.MouseWheelDelta);
+ newZoom = Math.Clamp(newZoom, MinZoomLevel, MaxZoomLevel);
+
+ var newPosition = basePosition.MultiplyBy(newZoom);
+ var delta = (newPosition.Subtract(oldPosition)).MultiplyBy(-1, 1);
+ var offset = ScrollValue.Add(delta);
+
+ // note: updating ZoomLevel can have side effects on ScrollValue:
+ // ZoomLevel --UpdateScrollBars-> ScrollBar.Maximum --clamp-> ScrollBar.Value --bound-> H/VScrollValue
+ // before we set the ZoomLevel, make sure to snapshot ScrollValue or finish the calculation using ScrollValue
+ ZoomLevel = newZoom;
+ SetScrollValue(offset, shouldClamp: false);
+
+ e.Handled = true;
+ }
+ // MouseWheel + Shift: Scroll Horizontally
+ // MouseWheel: Scroll Vertically
+ else
+ {
+ var magnitude = p.Properties.MouseWheelDelta * PanWheelRatio;
+ var delta = e.KeyModifiers.HasFlag(Windows.System.VirtualKeyModifiers.Shift)
+ ? new Point(magnitude, 0)
+ : new Point(0, -magnitude);
+ var offset = ScrollValue.Add(delta);
+
+ SetScrollValue(offset);
+ e.Handled = true;
+ }
+ }
+
+ public void ResetViewport()
+ {
+ ResetZoom();
+ ResetOffset();
+ }
+
+ internal void ResetZoom() => ZoomLevel = 1;
+
+ private void ResetOffset()
+ {
+ HorizontalScrollValue = 0;
+ VerticalScrollValue = 0;
+ }
+
+ public void FitToCanvas()
+ {
+ if (IsActive && Viewport is { } vp)
+ {
+ var hZoom = (vp.ActualWidth) / ScrollExtentWidth;
+ var vZoom = (vp.ActualHeight) / ScrollExtentHeight;
+ var zoomLevel = Math.Min(vZoom, hZoom);
+
+ ZoomLevel = Math.Clamp(zoomLevel, MinZoomLevel, MaxZoomLevel);
+ ResetOffset();
+ }
+ }
+
+ private void SetScrollValue(Point value, bool shouldClamp = true)
+ {
+ // we allow unconstrained panning for MouseWheelZoom(desktop) and PinchToZoom(mobile)
+ // where the focal point should remain stationary after zooming.
+ if (shouldClamp)
+ {
+ value.X = Math.Clamp(value.X, HorizontalMinScroll, HorizontalMaxScroll);
+ value.Y = Math.Clamp(value.Y, VerticalMinScroll, VerticalMaxScroll);
+ }
+
+ HorizontalScrollValue = value.X;
+ VerticalScrollValue = value.Y;
+ }
+
+ // Helper
+
+ private bool IsAllowedToWork => (IsLoaded && IsActive && _contentPresenter is not null);
+
+ private FrameworkElement? PresenterContent => _contentPresenter?.Content as FrameworkElement;
+
+ private FrameworkElement? Viewport => _contentGrid;
+
+ private Point ScrollValue => new Point(HorizontalScrollValue, VerticalScrollValue);
+
+ private double ScrollExtentWidth => (_contentSize.Width + AdditionalMargin.Left + AdditionalMargin.Right) * ZoomLevel;
+ private double ScrollExtentHeight => (_contentSize.Height + AdditionalMargin.Top + AdditionalMargin.Bottom) * ZoomLevel;
+}
diff --git a/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.xaml b/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.xaml
new file mode 100644
index 000000000..ed43d5201
--- /dev/null
+++ b/src/Uno.Toolkit.UI/Controls/ZoomContentControl/ZoomContentControl.xaml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
diff --git a/src/Uno.Toolkit.UI/Extensions/TwoDExtensions.cs b/src/Uno.Toolkit.UI/Extensions/TwoDExtensions.cs
new file mode 100644
index 000000000..855975380
--- /dev/null
+++ b/src/Uno.Toolkit.UI/Extensions/TwoDExtensions.cs
@@ -0,0 +1,41 @@
+#if !HAS_UNO
+#define MISSING_POINT_ARITHMETICS
+#endif
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+using System.Threading.Tasks;
+using Windows.Foundation;
+using Windows.UI.WebUI;
+
+namespace Uno.Toolkit.UI;
+
+internal static partial class TwoDExtensions // Point Mathematics
+{
+ public static Point ToPoint(this Size x) => new Point(x.Width, x.Height);
+ public static Point ToPoint(this Vector2 x) => new Point(x.X, x.Y);
+
+#if MISSING_POINT_ARITHMETICS
+ public static Point Add(this Point x, Point y) => new Point(x.X + y.X, x.Y + y.Y);
+ public static Point Subtract(this Point x, Point y) => new Point(x.X - y.X, x.Y - y.Y);
+#else
+ public static Point Add(this Point x, Point y) => x + y;
+ public static Point Subtract(this Point x, Point y) => x - y;
+#endif
+ public static Point MultiplyBy(this Point x, double scale) => new Point(x.X * scale, x.Y * scale);
+ public static Point MultiplyBy(this Point x, double scaleX, double scaleY) => new Point(x.X * scaleX, x.Y * scaleY);
+ public static Point DivideBy(this Point x, double scale) => new Point(x.X / scale, x.Y / scale);
+}
+
+internal static partial class TwoDExtensions // Size Mathematics
+{
+ public static Size ToSize(this Point x) => new Size(x.X, x.Y);
+ public static Size ToSize(this Vector2 x) => new Size(x.X, x.Y);
+
+ public static Size MultiplyBy(this Size x, double scale) => new Size(x.Width * scale, x.Height * scale);
+ public static Size MultiplyBy(this Size x, double scaleX, double scaleY) => new Size(x.Width * scaleX, x.Width * scaleY);
+ public static Size DivideBy(this Size x, double scale) => new Size(x.Width / scale, x.Height / scale);
+}