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 + } +}