diff --git a/doc/helpers/responsive-extension.md b/doc/helpers/responsive-extension.md
new file mode 100644
index 000000000..97a03475a
--- /dev/null
+++ b/doc/helpers/responsive-extension.md
@@ -0,0 +1,110 @@
+---
+uid: Toolkit.Helpers.ResponsiveExtension
+---
+# ResponsiveExtension
+
+## Summary
+The `ResponsiveExtension` class is a markup extension that enables the customization of `UIElement` properties based on screen size.
+This functionality provides a dynamic and responsive user interface experience.
+
+### Inheritance
+Object → MarkupExtension → ResponsiveExtension
+
+### Constructors
+| Constructor | Description |
+|-----------------------|----------------------------------------------------------------|
+| ResponsiveExtension() | Initializes a new instance of the `ResponsiveExtension` class. |
+
+## Properties
+| Property | Type | Description |
+| ---------- | ---------------- | ---------------------------------------------------------- |
+| Narrowest | object | Value to be used when the screen size is at its narrowest. |
+| Narrow | object | Value to be used when the screen size is narrow. |
+| Normal | object | Value to be used when the screen size is normal. |
+| Wide | object | Value to be used when the screen size is wide. |
+| Widest | object | Value to be used when the screen size is at its widest. |
+| Layout | ResponsiveLayout | Overrides the screen size thresholds/breakpoints. |
+
+### ResponsiveLayout
+Provides the ability to override the default breakpoints (i.e., the window widths at which the value changes) for the screen sizes.
+This is done using an instance of the `ResponsiveLayout` class.
+
+#### Properties
+| Property | Type | Description |
+| ---------- | ---------------- | ---------------------- |
+| Narrowest | double | Default value is 150. |
+| Narrow | double | Default value is 300. |
+| Normal | double | Default value is 600. |
+| Wide | double | Default value is 800. |
+| Widest | double | Default value is 1080. |
+
+## Remarks
+**Platform limitation**: The ability to update property values when the window size changes is only available on targets other than Windows UWP.
+Due to a limitation of the UWP API (Windows target only), the `MarkupExtension.ProvideValue(IXamlServiceProvider)` overload is unavailable, which is required to continuously update the value.
+Because of this, the markup extension will only provide the initial value, and will not respond to window size changes.
+
+**Initialization**: The `ResponsiveHelper` needs to be hooked up to the window's `SizeChanged` event in order for it to receive updates when the window size changes.
+This is typically done in the `OnLaunched` method in the `App` class, where you can get the current window and call the `HookupEvent` method on the `ResponsiveHelper`.
+It is important to do this when the app launches, otherwise the `ResponsiveExtension` won't be able to update the property values when the window size changes.
+
+Here is an example of how this might be achieved:
+
+```cs
+protected override void OnLaunched(LaunchActivatedEventArgs args)
+{
+#if NET6_0_OR_GREATER && WINDOWS && !HAS_UNO
+ MainWindow = new Window();
+#else
+ MainWindow = Microsoft.UI.Xaml.Window.Current;
+#endif
+ // ...
+ var helper = Uno.Toolkit.UI.ResponsiveHelper.GetForCurrentView();
+ helper.HookupEvent(MainWindow);
+}
+```
+
+**Property type limitation**: Content property values other than strings must be appropriately typed for the markup extension to interpret them correctly.
+In the basic usage example below, the values `NarrowRed` and `WideBlue` are properly typed as they refer to the `StaticResource` keys defined in `Page.Resources`.
+For instance, using `Background={utu:Responsive Narrow=SkyBlue, Wide=Pink}` would be incorrect, while the string literal usage under `` is accepted.
+
+## Usage
+
+### Basic example
+```xml
+xmlns:utu="using:Uno.Toolkit.UI"
+...
+
+ Crimson
+ Blue
+
+...
+
+```
+
+### Custom thresholds
+```xml
+xmlns:utu="using:Uno.Toolkit.UI"
+xmlns:hlp="using:Uno.Toolkit.UI.Helpers"
+...
+
+
+
+...
+
+
+
+
+
+```
+
diff --git a/doc/toc.yml b/doc/toc.yml
index 24b0836f3..d0f2a5493 100644
--- a/doc/toc.yml
+++ b/doc/toc.yml
@@ -59,6 +59,9 @@
href: helpers/input-extensions.md
- name: ItemsRepeater Extensions
href: helpers/itemsrepeater-extensions.md
+ - name: Responsive markup extension
+ href: helpers/responsive-extension.md
+ - name: StatusBar attached properties
- name: Resource Extensions
href: helpers/resource-extensions.md
- name: Selector Extensions
diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Helpers/ResponsiveExtensionsSamplePage.xaml b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Helpers/ResponsiveExtensionsSamplePage.xaml
new file mode 100644
index 000000000..3bb63f677
--- /dev/null
+++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Helpers/ResponsiveExtensionsSamplePage.xaml
@@ -0,0 +1,73 @@
+
+
+ Vertical
+ Horizontal
+ 15
+ 25
+ Crimson
+ Blue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Helpers/ResponsiveExtensionsSamplePage.xaml.cs b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Helpers/ResponsiveExtensionsSamplePage.xaml.cs
new file mode 100644
index 000000000..294a1111e
--- /dev/null
+++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Helpers/ResponsiveExtensionsSamplePage.xaml.cs
@@ -0,0 +1,18 @@
+using Uno.Toolkit.Samples.Entities;
+
+#if IS_WINUI
+using Microsoft.UI.Xaml.Controls;
+#else
+using Windows.UI.Xaml.Controls;
+#endif
+
+namespace Uno.Toolkit.Samples.Content.Helpers;
+
+[SamplePage(SampleCategory.Helpers, "Responsive Extensions", SourceSdk.UnoToolkit)]
+public sealed partial class ResponsiveExtensionsSamplePage : Page
+{
+ public ResponsiveExtensionsSamplePage()
+ {
+ this.InitializeComponent();
+ }
+}
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 344949c4e..34db87b9a 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
@@ -71,6 +71,9 @@
BindingExtensionsSamplePage.xaml
+
+ ResponsiveExtensionsSamplePage.xaml
+
@@ -296,6 +299,10 @@
Designer
MSBuild:Compile
+
+ Designer
+ MSBuild:Compile
+
Designer
MSBuild:Compile
diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveExtensionsTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveExtensionsTests.cs
new file mode 100644
index 000000000..54d388945
--- /dev/null
+++ b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveExtensionsTests.cs
@@ -0,0 +1,192 @@
+// Disabled until fix is implemented for https://github.com/unoplatform/uno/issues/14620
+
+//using System.Threading.Tasks;
+//using Microsoft.VisualStudio.TestTools.UnitTesting;
+//using Uno.UI.RuntimeTests;
+//using Uno.Toolkit.RuntimeTests.Helpers;
+//using Uno.Toolkit.UI;
+//using Windows.Foundation;
+
+//#if IS_WINUI
+//using Microsoft.UI.Xaml.Controls;
+//using Microsoft.UI;
+//using Microsoft.UI.Xaml.Media;
+//#else
+//using Windows.UI.Xaml.Controls;
+//using Windows.UI;
+//using Windows.UI.Xaml.Media;
+//#endif
+
+//namespace Uno.Toolkit.RuntimeTests.Tests;
+
+//[TestClass]
+//[RunsOnUIThread]
+//internal class ResponsiveExtensionsTests
+//{
+// private static readonly Size NarrowSize = new Size(300, 400);
+// private static readonly Size WideSize = new Size(800, 400);
+
+// [TestMethod]
+// public async Task ProvideValue_String_InitialValue()
+// {
+// using (ResponsiveHelper.UsingDebuggableInstance())
+// {
+// ResponsiveHelper.SetDebugSize(NarrowSize);
+
+// var host = XamlHelper.LoadXaml("""
+//
+// """);
+
+// await UnitTestUIContentHelperEx.SetContentAndWait(host);
+
+// Assert.AreEqual("asd", host.Text);
+// }
+// }
+
+//#if !IS_UWP || HAS_UNO
+// [TestMethod]
+// public async Task ProvideValue_String_SizeChange()
+// {
+// using (ResponsiveHelper.UsingDebuggableInstance())
+// {
+// ResponsiveHelper.SetDebugSize(NarrowSize);
+
+// var host = XamlHelper.LoadXaml("""
+//
+// """);
+
+// await UnitTestUIContentHelperEx.SetContentAndWait(host);
+
+// Assert.AreEqual("asd", host.Text);
+
+// ResponsiveHelper.SetDebugSize(WideSize);
+
+// Assert.AreEqual("qwe", host.Text);
+// }
+// }
+//#endif
+
+// [TestMethod]
+// public async Task ProvideValue_Color_InitialValue()
+// {
+// using (ResponsiveHelper.UsingDebuggableInstance())
+// {
+// ResponsiveHelper.SetDebugSize(NarrowSize);
+
+// var border = XamlHelper.LoadXaml("""
+//
+//
+// Red
+// Blue
+//
+//
+//
+//
+//
+// """);
+
+// await UnitTestUIContentHelperEx.SetContentAndWait(border);
+
+// Assert.AreEqual(Colors.Red, ((SolidColorBrush)border.Background).Color);
+// }
+// }
+
+//#if !IS_UWP || HAS_UNO
+// [TestMethod]
+// public async Task ProvideValue_Color_SizeChange()
+// {
+// using (ResponsiveHelper.UsingDebuggableInstance())
+// {
+// ResponsiveHelper.SetDebugSize(NarrowSize);
+
+// var border = XamlHelper.LoadXaml("""
+//
+//
+// Red
+// Blue
+//
+//
+//
+//
+//
+// """);
+
+// await UnitTestUIContentHelperEx.SetContentAndWait(border);
+
+// Assert.AreEqual(Colors.Red, ((SolidColorBrush)border.Background).Color);
+
+// ResponsiveHelper.SetDebugSize(WideSize);
+
+// Assert.AreEqual(Colors.Blue, ((SolidColorBrush)border.Background).Color);
+
+// }
+// }
+//#endif
+
+// [TestMethod]
+// public async Task ProvideValue_Orientation_InitialValue()
+// {
+// using (ResponsiveHelper.UsingDebuggableInstance())
+// {
+// ResponsiveHelper.SetDebugSize(NarrowSize);
+
+// var host = XamlHelper.LoadXaml("""
+//
+//
+// Vertical
+// Horizontal
+//
+//
+//
+//
+//
+//
+//
+// """);
+
+// var stackPanel = (StackPanel)host.FindName("MyStackPanel");
+
+// await UnitTestUIContentHelperEx.SetContentAndWait(host);
+
+// Assert.AreEqual(Orientation.Vertical, stackPanel.Orientation);
+// }
+// }
+
+//#if !IS_UWP || HAS_UNO
+// [TestMethod]
+// public async Task ProvideValue_Orientation_SizeChange()
+// {
+// using (ResponsiveHelper.UsingDebuggableInstance())
+// {
+// ResponsiveHelper.SetDebugSize(NarrowSize);
+
+// var host = XamlHelper.LoadXaml("""
+//
+//
+// Vertical
+// Horizontal
+//
+//
+//
+//
+//
+//
+//
+// """);
+
+// var stackPanel = (StackPanel)host.FindName("MyStackPanel");
+
+// await UnitTestUIContentHelperEx.SetContentAndWait(host);
+
+// Assert.AreEqual(Orientation.Vertical, stackPanel.Orientation);
+
+// ResponsiveHelper.SetDebugSize(WideSize);
+
+// Assert.AreEqual(Orientation.Horizontal, stackPanel.Orientation);
+// }
+// }
+//#endif
+
+//}
diff --git a/src/Uno.Toolkit.UI/Extensions/DependencyObjectExtensions.cs b/src/Uno.Toolkit.UI/Extensions/DependencyObjectExtensions.cs
index 3133e4a1c..522fd1ebd 100644
--- a/src/Uno.Toolkit.UI/Extensions/DependencyObjectExtensions.cs
+++ b/src/Uno.Toolkit.UI/Extensions/DependencyObjectExtensions.cs
@@ -214,6 +214,32 @@ internal static void SetParent(this DependencyObject dependencyObject, object? p
return property;
}
+ public static DependencyProperty? FindDependencyPropertyUsingReflection(this DependencyObject dependencyObject, string propertyName)
+ {
+ var type = dependencyObject.GetType();
+ var key = (ownerType: type, propertyName);
+
+ if (_dependencyPropertyReflectionCache.TryGetValue(key, out var property))
+ {
+ return property;
+ }
+
+ property =
+ type.GetProperty(propertyName, Public | Static | FlattenHierarchy)?.GetValue(null) as DependencyProperty ??
+ type.GetField(propertyName, Public | Static | FlattenHierarchy)?.GetValue(null) as DependencyProperty;
+
+#if HAS_UNO
+ if (property == null)
+ {
+ typeof(DependencyObjectExtensions).Log().LogWarning($"The '{type}.{propertyName}' dependency property does not exist.");
+ }
+#endif
+
+ _dependencyPropertyReflectionCache[key] = property;
+
+ return property;
+ }
+
public static bool TryGetValue(this DependencyObject dependencyObject, DependencyProperty dependencyProperty, out DependencyObject? value)
{
value = default;
diff --git a/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs
index 925d53034..220277ecb 100644
--- a/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs
+++ b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs
@@ -1,8 +1,11 @@
-using System.Collections.Generic;
+#if HAS_UNO
+#define UNO14502_WORKAROUND // https://github.com/unoplatform/uno/issues/14502
+#endif
+
using System;
+using System.Collections.Generic;
using Windows.Foundation;
using Uno.Disposables;
-
#if IS_WINUI
using Microsoft.UI.Xaml;
#else
@@ -108,10 +111,14 @@ public double Widest
internal class ResponsiveHelper
{
private static readonly Lazy _instance = new Lazy(() => new ResponsiveHelper());
- private readonly List _references = new();
private static readonly ResponsiveHelper _debugInstance = new();
private static bool UseDebuggableInstance;
+ private readonly List _callbacks = new();
+#if UNO14502_WORKAROUND
+ private List _hardCallbackReferences = new();
+#endif
+
public ResponsiveLayout Layout { get; private set; } = ResponsiveLayout.Create(150, 300, 600, 800, 1080);
public Size WindowSize { get; private set; } = Size.Empty;
@@ -126,19 +133,35 @@ public void HookupEvent(Window window)
window.SizeChanged += OnWindowSizeChanged;
}
- internal static void SetDebugSize(Size size) => _debugInstance.OnWindowSizeChanged(size);
private void OnWindowSizeChanged(object sender, WindowSizeChangedEventArgs e) => OnWindowSizeChanged(e.Size);
private void OnWindowSizeChanged(Size size)
{
WindowSize = size;
- _references.RemoveAll(reference => !reference.IsAlive);
+ // Clean up collected references
+ _callbacks.RemoveAll(reference => !reference.IsAlive);
- foreach (var reference in _references.ToArray())
+ foreach (var reference in _callbacks.ToArray())
{
if (reference.IsAlive && reference.Target is IResponsiveCallback callback)
{
+#if UNO14502_WORKAROUND
+ // Note: In ResponsiveExtensionsSamplePage, if we are using SamplePageLayout with the template,
+ // it seems to keep the controls (_weakTarget) alive, even if we navigate out and back (new page).
+ // However, if we remove the SamplePageLayout, and add the template as a child instead,
+ // the controls will be properly collected.
+
+ // We are using a hard reference to keep the markup extension alive.
+ // We need to check if its reference target is still alive. If it is not, then it should be removed.
+ if (callback is ResponsiveExtension { _weakTarget: { IsAlive: false } })
+ {
+ _hardCallbackReferences.Remove(callback);
+ _callbacks.Remove(reference);
+
+ continue;
+ }
+#endif
callback.OnSizeChanged(WindowSize, Layout);
}
}
@@ -146,8 +169,16 @@ private void OnWindowSizeChanged(Size size)
internal void Register(IResponsiveCallback host)
{
+#if UNO14502_WORKAROUND
+ // The workaround is only needed for ResponsiveExtension (MarkupExtension)
+ if (host is ResponsiveExtension)
+ {
+ _hardCallbackReferences.Add(host);
+ }
+#endif
+
var wr = new WeakReference(host);
- _references.Add(wr);
+ _callbacks.Add(wr);
}
internal static IDisposable UsingDebuggableInstance()
@@ -156,4 +187,6 @@ internal static IDisposable UsingDebuggableInstance()
return Disposable.Create(() => UseDebuggableInstance = false);
}
+
+ internal static void SetDebugSize(Size size) => _debugInstance.OnWindowSizeChanged(size);
}
diff --git a/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.cs b/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.cs
new file mode 100644
index 000000000..ad62fb97a
--- /dev/null
+++ b/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Linq;
+using Uno.Extensions;
+using Uno.Logging;
+using Windows.Foundation;
+
+#if IS_WINUI
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Markup;
+#else
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Markup;
+#endif
+
+namespace Uno.Toolkit.UI;
+
+///
+/// A markup extension that updates a property based on the current window width.
+///
+public partial class ResponsiveExtension : MarkupExtension, IResponsiveCallback
+{
+ internal WeakReference? _weakTarget;
+#if !WINDOWS_UWP
+ private DependencyProperty? _targetProperty;
+#endif
+ public object? Narrowest { get; set; }
+ public object? Narrow { get; set; }
+ public object? Normal { get; set; }
+ public object? Wide { get; set; }
+ public object? Widest { get; set; }
+
+ public ResponsiveLayout? Layout { get; set; }
+
+ public ResponsiveExtension()
+ {
+ }
+
+#if WINDOWS_UWP
+ ///
+ protected override object? ProvideValue()
+ {
+ this.Log().WarnIfEnabled(() => "The property value, once initially set, cannot be updated due to UWP limitation. Consider upgrading to WinUI, on which the service provider context is exposed through a ProvideValue overload.");
+ return GetInitialValue();
+ }
+#else
+ ///
+ protected override object? ProvideValue(IXamlServiceProvider serviceProvider)
+ {
+ BindToSizeChanged(serviceProvider);
+
+ return GetInitialValue();
+ }
+#endif
+
+ private object? GetInitialValue()
+ {
+ var helper = ResponsiveHelper.GetForCurrentView();
+
+ return GetValueForSize(helper.WindowSize, Layout ?? helper.Layout);
+ }
+
+ private object? GetValueForSize(Size size, ResponsiveLayout layout)
+ {
+ var defs = new (double MinWidth, object? Value)?[]
+ {
+ (layout.Narrowest, Narrowest),
+ (layout.Narrow, Narrow),
+ (layout.Normal, Normal),
+ (layout.Wide, Wide),
+ (layout.Widest, Widest),
+ }.Where(x => x?.Value != null).ToArray();
+
+ var match = defs.FirstOrDefault(y => y?.MinWidth >= size.Width) ?? defs.LastOrDefault();
+
+ return match?.Value;
+ }
+
+#if !WINDOWS_UWP
+ private void BindToSizeChanged(IXamlServiceProvider serviceProvider)
+ {
+ if (serviceProvider.GetService(typeof(IProvideValueTarget)) is IProvideValueTarget pvt &&
+ pvt.TargetObject is FrameworkElement target &&
+ pvt.TargetProperty is ProvideValueTargetProperty pvtp &&
+ target.FindDependencyPropertyUsingReflection($"{pvtp?.Name}Property") is DependencyProperty dp)
+ {
+ _weakTarget = new WeakReference(target);
+ _targetProperty = dp;
+
+ ResponsiveHelper.GetForCurrentView().Register(this);
+ }
+ else
+ {
+ this.Log().Error($"Failed to register {nameof(ResponsiveExtension)}");
+ }
+ }
+#endif
+
+ public void OnSizeChanged(Size size, ResponsiveLayout layout)
+ {
+#if !WINDOWS_UWP
+ if (_weakTarget?.IsAlive == true &&
+ _weakTarget.Target is FrameworkElement target &&
+ _targetProperty is not null)
+ {
+ target.SetValue(_targetProperty, GetValueForSize(size, Layout ?? layout));
+ }
+#endif
+ }
+}