diff --git a/doc/controls/ResponsiveView.md b/doc/controls/ResponsiveView.md index 4985a914a..99dd0aa42 100644 --- a/doc/controls/ResponsiveView.md +++ b/doc/controls/ResponsiveView.md @@ -9,23 +9,6 @@ The `ResponsiveView` provides the ability to display different content based on The `ResponsiveView` control adapts to different screen sizes by dynamically choosing the right template. It looks at the current screen width and the defined templates. Since not all templates need a value, the control ensures a smooth user experience by picking the smallest defined template that satisfies the width requirements. If no match is found, it defaults to the largest defined template. -**Initialization**: The `ResponsiveHelper` needs to be hooked up to the window's `SizeChanged` event in order for this control 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` instance for `ResponsiveHelper.HookupEvent`: -```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); -} -``` - ## Properties | Property | Type | Description | | ----------------- | ---------------- | ------------------------------------------------------- | @@ -142,8 +125,8 @@ xmlns:utu="using:Uno.Toolkit.UI" ``` > [!NOTE] -> This `ResponsiveLayout` can also be provided from different locations. In order of precedences, they are: +> The `ResponsiveLayout` can also be provided from different locations. In order of precedences, they are: > - from the `.ResponsiveLayout` property > - in `ResponsiveView`'s parent `.Resources` with `x:Key="DefaultResponsiveLayout"`, or its ancestor's... > - in `Application.Resources` with `x:Key="DefaultResponsiveLayout"` -> - from the hardcoded `ResponsiveHelper.Layout` +> - from the hardcoded `ResponsiveHelper.DefaultLayout` which is defined as [150/300/600/800/1080] diff --git a/doc/helpers/responsive-extension.md b/doc/helpers/responsive-extension.md index da30b3089..ff994e4b9 100644 --- a/doc/helpers/responsive-extension.md +++ b/doc/helpers/responsive-extension.md @@ -7,26 +7,8 @@ uid: Toolkit.Helpers.ResponsiveExtension 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. -## Remarks -**Initialization**: The `ResponsiveHelper` needs to be hooked up to the window's `SizeChanged` event in order for this markup 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` instance for `ResponsiveHelper.HookupEvent`: -```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); -} -``` - ## Platform limitation (UWP-desktop) -`ResponsiveExtension` relies on `MarkupExtension.ProvideValue(IXamlServiceProvider)` to find the target control and property for continuous value updates, and to obtain the property type to apply automatic type conversion, as its value properties are parsed as string by the XAML engine. Since this overload is a recent addition exclusive to WinUI, UWP projects targeting Windows won't have access to these features. Uno UWP projects targeting non-Windows platforms do not face this limitation. However, the Windows app may crash or present unexpected behavior if you attempt to use this markup on a non-string property. +`ResponsiveExtension` relies on `MarkupExtension.ProvideValue(IXamlServiceProvider)` to find the target control and property for continuous value updates, and to obtain the property type to apply automatic type conversion, as its value properties are parsed as `string` by the XAML engine. Since this overload is a recent addition exclusive to WinUI, UWP projects targeting Windows won't have access to these features. Uno UWP projects targeting non-Windows platforms do not face this limitation. However, the Windows app may crash or present unexpected behavior if you attempt to use this markup on a non-`string` property. ```xml @@ -141,5 +123,5 @@ xmlns:utu="using:Uno.Toolkit.UI" > - from the `Layout` property > - in the property owner's parent `.Resources` with `x:Key="DefaultResponsiveLayout"`, or the property owner's parent's parent's... > - in `Application.Resources` with `x:Key="DefaultResponsiveLayout"` -> - from the hardcoded `ResponsiveHelper.Layout` +> - from the hardcoded `ResponsiveHelper.DefaultLayout` which is defined as [150/300/600/800/1080] diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/App.xaml.cs b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/App.xaml.cs index fcfe1abff..80a00535d 100644 --- a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/App.xaml.cs +++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/App.xaml.cs @@ -98,8 +98,6 @@ protected override async void OnLaunched(XamlLaunchActivatedEventArgs e) #else _window = XamlWindow.Current; #endif - var helper = ResponsiveHelper.GetForCurrentView(); - helper.HookupEvent(_window); if (_window.Content is null) { diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveExtensionsTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveExtensionsTests.cs index 54d388945..d4752eb8e 100644 --- a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveExtensionsTests.cs +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveExtensionsTests.cs @@ -1,192 +1,160 @@ -// 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 - -//} +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; +using Uno.UI.RuntimeTests; +using Uno.Toolkit.RuntimeTests.Helpers; +using Uno.Toolkit.UI; + +#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] +#if HAS_UNO +[Ignore("blocked by #14620: dynamically loaded MarkupExtension are not initialized.")] // https://github.com/unoplatform/uno/issues/14620 +#elif IS_UWP +[Ignore("ResponsiveExtension is not supported on UWP.")] +#endif +[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_Value() + { + var sut = XamlHelper.LoadXaml(""" + + """); + var ext = ResponsiveExtension.GetInstanceFor(sut, nameof(sut.Text)) ?? throw new InvalidOperationException("Failed to resolve the markup extension."); + await UnitTestUIContentHelperEx.SetContentAndWait(sut); + + ext.ForceResponsiveSize(NarrowSize); + Assert.AreEqual("asd", sut.Text); + } + + [TestMethod] + public async Task ProvideValue_String_SizeChange() + { + var sut = XamlHelper.LoadXaml(""" + + """); + var ext = ResponsiveExtension.GetInstanceFor(sut, nameof(sut.Text)) ?? throw new InvalidOperationException("Failed to resolve the markup extension."); + await UnitTestUIContentHelperEx.SetContentAndWait(sut); + + ext.ForceResponsiveSize(NarrowSize); + Assert.AreEqual("asd", sut.Text); + + ext.ForceResponsiveSize(WideSize); + Assert.AreEqual("qwe", sut.Text); + } + + [TestMethod] + public async Task ProvideValue_Color_Value() + { + var sut = XamlHelper.LoadXaml(""" + + + Red + Blue + + + + + + """); + var ext = ResponsiveExtension.GetInstanceFor(sut, nameof(sut.Background)) ?? throw new InvalidOperationException("Failed to resolve the markup extension."); + await UnitTestUIContentHelperEx.SetContentAndWait(sut); + + ext.ForceResponsiveSize(NarrowSize); + Assert.AreEqual(Colors.Red, ((SolidColorBrush)sut.Background).Color); + } + + [TestMethod] + public async Task ProvideValue_Color_SizeChange() + { + var sut = XamlHelper.LoadXaml(""" + + + Red + Blue + + + + + + """); + var ext = ResponsiveExtension.GetInstanceFor(sut, nameof(sut.Background)) ?? throw new InvalidOperationException("Failed to resolve the markup extension."); + await UnitTestUIContentHelperEx.SetContentAndWait(sut); + + ext.ForceResponsiveSize(NarrowSize); + Assert.AreEqual(Colors.Red, ((SolidColorBrush)sut.Background).Color); + + ext.ForceResponsiveSize(WideSize); + Assert.AreEqual(Colors.Blue, ((SolidColorBrush)sut.Background).Color); + } + + [TestMethod] + public async Task ProvideValue_Orientation_Value() + { + var container = XamlHelper.LoadXaml(""" + + + Vertical + Horizontal + + + + + + + + """); + var sut = container.Child as StackPanel ?? throw new InvalidOperationException("Failed to resolve the SUT"); + var ext = ResponsiveExtension.GetInstanceFor(sut, nameof(sut.Orientation)) ?? throw new InvalidOperationException("Failed to resolve the markup extension."); + await UnitTestUIContentHelperEx.SetContentAndWait(container); + + ext.ForceResponsiveSize(NarrowSize); + Assert.AreEqual(Orientation.Vertical, (container.Child as StackPanel)?.Orientation); + } + + [TestMethod] + public async Task ProvideValue_Orientation_SizeChange() + { + var container = XamlHelper.LoadXaml(""" + + + Vertical + Horizontal + + + + + + + + """); + var sut = container.Child as StackPanel ?? throw new InvalidOperationException("Failed to resolve the SUT"); + var ext = ResponsiveExtension.GetInstanceFor(sut, nameof(sut.Orientation)) ?? throw new InvalidOperationException("Failed to resolve the markup extension."); + await UnitTestUIContentHelperEx.SetContentAndWait(container); + + ext.ForceResponsiveSize(NarrowSize); + Assert.AreEqual(Orientation.Vertical, sut.Orientation); + + ext.ForceResponsiveSize(WideSize); + Assert.AreEqual(Orientation.Horizontal, sut.Orientation); + } +} diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs index f94a16a90..16d328fbb 100644 --- a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs @@ -22,7 +22,7 @@ public class ResponsiveHelperTests public void When_Resolving_AllLayout() { var layout = DefaultLayout; - var options = Enum.GetValues(); + var options = new[] { Layout.Narrowest, Layout.Narrow, Layout.Normal, Layout.Wide, Layout.Widest }; Assert.AreEqual(Layout.Narrowest, ResponsiveHelper.ResolveLayoutCore(layout, 149, options), "149"); Assert.AreEqual(Layout.Narrowest, ResponsiveHelper.ResolveLayoutCore(layout, 150, options), "150"); // breakpoint=Narrowest diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs index f1412d8a2..946787971 100644 --- a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs @@ -22,183 +22,162 @@ internal class ResponsiveViewTests [TestMethod] public async Task ResponsiveView_NarrowContent_TextBlock() { - using (ResponsiveHelper.UsingDebuggableInstance()) - { - ResponsiveHelper.SetDebugSize(new Size(300, 400)); - - var host = XamlHelper.LoadXaml(""" - - - - - - - - - - - - - """); - - await UnitTestUIContentHelperEx.SetContentAndWait(host); - - var element = (TextBlock)host.Content; - - Assert.AreEqual("Narrow", element.Text); - } + var host = XamlHelper.LoadXaml(""" + + + + + + + + + + + + + """); + await UnitTestUIContentHelperEx.SetContentAndWait(host); + + host.ForceResponsiveSize(new Size(300, 400)); + Assert.AreEqual("Narrow", (host.Content as TextBlock)?.Text); } [TestMethod] public async Task ResponsiveView_NormalContent_Rectangle() { - using (ResponsiveHelper.UsingDebuggableInstance()) - { - ResponsiveHelper.SetDebugSize(new Size(600, 400)); - - var host = XamlHelper.LoadXaml(""" - - - - - - - - - - - - - - - - - - """); - - await UnitTestUIContentHelperEx.SetContentAndWait(host); - - Assert.AreEqual(typeof(Rectangle), host.Content.GetType()); - } + var host = XamlHelper.LoadXaml(""" + + + + + + + + + + + + + + + + + + """); + await UnitTestUIContentHelperEx.SetContentAndWait(host); + + host.ForceResponsiveSize(new Size(600, 400)); + Assert.AreEqual(typeof(Rectangle), host.Content?.GetType()); } [TestMethod] public async Task ResponsiveView_NormalContent_ResponsiveLayout() { - using (ResponsiveHelper.UsingDebuggableInstance()) - { - ResponsiveHelper.SetDebugSize(new Size(322, 400)); - - var host = XamlHelper.LoadXaml(""" - - - - 350 - 450 - 800 - 1200 - 1500 - - - - - - - - - - - - - - - - - - - """); - - await UnitTestUIContentHelperEx.SetContentAndWait(host); - - Assert.AreEqual(typeof(Ellipse), host.Content.GetType()); - } + var host = XamlHelper.LoadXaml(""" + + + + + + + + + + + + + + + + + + + + """); + host.ResponsiveLayout = + // somehow neither attribute or member syntax work on windows... + // The attachable property 'Narrowest' was not found in type 'ResponsiveLayout'. [Line: 4 Position: 6]' + ResponsiveLayout.Create(350, 450, 800, 1200, 1500); + await UnitTestUIContentHelperEx.SetContentAndWait(host); + + host.ForceResponsiveSize(new Size(322, 400)); + Assert.AreEqual(typeof(Ellipse), host.Content?.GetType()); } [TestMethod] public async Task ResponsiveView_WidestContent_Ellipse() { - using (ResponsiveHelper.UsingDebuggableInstance()) - { - ResponsiveHelper.SetDebugSize(new Size(2000, 400)); - - var host = XamlHelper.LoadXaml(""" - - - - - - - - - - - - - - - - - - - - - - - """); - - await UnitTestUIContentHelperEx.SetContentAndWait(host); - - Assert.AreEqual(typeof(Ellipse), host.Content.GetType()); - } + var host = XamlHelper.LoadXaml(""" + + + + + + + + + + + + + + + + + + + + + + + """); + await UnitTestUIContentHelperEx.SetContentAndWait(host); + + host.ForceResponsiveSize(new Size(2000, 400)); + Assert.AreEqual(typeof(Ellipse), host.Content?.GetType()); } [TestMethod] public async Task ResponsiveView_WideContent_SizeChanged() { - using (ResponsiveHelper.UsingDebuggableInstance()) - { - ResponsiveHelper.SetDebugSize(new Size(150, 400)); - - var host = XamlHelper.LoadXaml(""" - - - - - - - - - - - - - - - - - - - - - - - """); - - await UnitTestUIContentHelperEx.SetContentAndWait(host); - Assert.AreEqual(typeof(TextBlock), host.Content.GetType()); - - ResponsiveHelper.SetDebugSize(new Size(800, 400)); - Assert.AreEqual(typeof(TextBox), host.Content.GetType()); - } + var host = XamlHelper.LoadXaml(""" + + + + + + + + + + + + + + + + + + + + + + + """); + await UnitTestUIContentHelperEx.SetContentAndWait(host); + + host.ForceResponsiveSize(new Size(150, 400)); + Assert.AreEqual(typeof(TextBlock), host.Content?.GetType()); + + host.ForceResponsiveSize(new Size(800, 400)); + Assert.AreEqual(typeof(TextBox), host.Content?.GetType()); } } diff --git a/src/Uno.Toolkit.UI/Controls/ResponsiveView/ResponsiveView.cs b/src/Uno.Toolkit.UI/Controls/ResponsiveView/ResponsiveView.cs index e02e3d715..6477bff87 100644 --- a/src/Uno.Toolkit.UI/Controls/ResponsiveView/ResponsiveView.cs +++ b/src/Uno.Toolkit.UI/Controls/ResponsiveView/ResponsiveView.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Windows.Foundation; #if IS_WINUI @@ -11,31 +12,52 @@ namespace Uno.Toolkit.UI; -public partial class ResponsiveView : ContentControl, IResponsiveCallback +public partial class ResponsiveView : ContentControl { public Layout? CurrentLayout { get; private set; } - internal (ResponsiveLayout Layout, Size Size, Layout? Result) LastResolved { get; private set; } + internal ResolvedLayout? LastResolved { get; private set; } public ResponsiveView() { - this.DefaultStyleKey = typeof(ResponsiveView); + DefaultStyleKey = typeof(ResponsiveView); - ResponsiveHelper.GetForCurrentView().Register(this); + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (XamlRoot is null) return; + + UpdateTemplate(forceApplyValue: true); - Loaded += ResponsiveView_Loaded; + XamlRoot.Changed -= OnXamlRootPropertyChanged; + XamlRoot.Changed += OnXamlRootPropertyChanged; } - private void ResponsiveView_Loaded(object sender, RoutedEventArgs e) => UpdateTemplate(forceApplyValue: true); + private void OnXamlRootPropertyChanged(XamlRoot sender, XamlRootChangedEventArgs args) + { + if (sender.Size == LastResolved?.Size) return; - public void OnSizeChanged(ResponsiveHelper helper) => UpdateTemplate(helper); + UpdateTemplate(); + } - private void UpdateTemplate(ResponsiveHelper? helper = null, bool forceApplyValue = false) + internal void ForceResponsiveSize(Size size) { - if (!IsLoaded) return; + var resolved = ResponsiveHelper.ResolveLayout(size, GetAppliedLayout(), GetAvailableLayoutOptions()); + UpdateTemplate(resolved, forceApplyValue: true); + } - helper ??= ResponsiveHelper.GetForCurrentView(); - var resolved = helper.ResolveLayout(GetAppliedLayout(), GetAvailableLayoutOptions()); + private void UpdateTemplate(bool forceApplyValue = false) + { + if (!IsLoaded) return; + if (XamlRoot is null) return; + + var resolved = ResponsiveHelper.ResolveLayout(XamlRoot.Size, GetAppliedLayout(), GetAvailableLayoutOptions()); + UpdateTemplate(resolved, forceApplyValue); + } + private void UpdateTemplate(ResolvedLayout resolved, bool forceApplyValue = false) + { if (forceApplyValue || CurrentLayout != resolved.Result) { Content = GetTemplateFor(resolved.Result)?.LoadContent() as UIElement; @@ -54,6 +76,7 @@ private void UpdateTemplate(ResponsiveHelper? helper = null, bool forceApplyValu UI.Layout.Normal => NormalTemplate, UI.Layout.Wide => WideTemplate, UI.Layout.Widest => WidestTemplate, + _ => null, }; } diff --git a/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs index 1cd439dd7..c0a1b2229 100644 --- a/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs +++ b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs @@ -1,12 +1,7 @@ -#if HAS_UNO -#define UNO14502_WORKAROUND // https://github.com/unoplatform/uno/issues/14502 -#endif - using System; -using System.Linq; using System.Collections.Generic; +using System.Linq; using Windows.Foundation; -using Uno.Disposables; #if IS_WINUI using Microsoft.UI.Xaml; @@ -17,10 +12,7 @@ namespace Uno.Toolkit.UI; -internal interface IResponsiveCallback -{ - void OnSizeChanged(ResponsiveHelper sender); -} +public enum Layout { Narrowest, Narrow, Normal, Wide, Widest } public partial class ResponsiveLayout : DependencyObject { @@ -110,97 +102,31 @@ public double Widest Wide = wide, Widest = widest, }; - + public IEnumerable GetBreakpoints() => new[] { Narrowest, Narrow, Normal, Wide, Widest }; public override string ToString() => "[" + string.Join(",", Narrowest, Narrow, Normal, Wide, Widest) + "]"; } -public enum Layout { Narrowest, Narrow, Normal, Wide, Widest } +internal record ResolvedLayout(ResponsiveLayout Layout, Size Size, Layout? Result); -public class ResponsiveHelper +internal static class ResponsiveHelper { - private static readonly Lazy _instance = new Lazy(() => new ResponsiveHelper()); - private static readonly ResponsiveHelper _debugInstance = new(); - private static bool UseDebuggableInstance; - - private readonly List _callbacks = new(); -#if UNO14502_WORKAROUND - private readonly 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; - - public static ResponsiveHelper GetForCurrentView() => UseDebuggableInstance ? _debugInstance : _instance.Value; + public static ResponsiveLayout DefaultLayout { get; } = ResponsiveLayout.Create(150, 300, 600, 800, 1080); - private ResponsiveHelper() { } - - public void HookupEvent(Window window) + internal static ResolvedLayout ResolveLayout(Size size, ResponsiveLayout? layout, IEnumerable options) { - WindowSize = new Size(window.Bounds.Width, window.Bounds.Height); + layout ??= DefaultLayout; + var result = ResolveLayoutCore(layout, size.Width, options); - window.SizeChanged += OnWindowSizeChanged; - } - - private void OnWindowSizeChanged(object sender, WindowSizeChangedEventArgs e) => OnWindowSizeChanged(e.Size); - - private void OnWindowSizeChanged(Size size) - { - WindowSize = size; - - // Clean up collected references - _callbacks.RemoveAll(reference => !reference.IsAlive); - - foreach (var reference in _callbacks.ToArray()) - { - if (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 { TargetWeakRef: { IsAlive: false } }) - { - _hardCallbackReferences.Remove(callback); - _callbacks.Remove(reference); - - continue; - } -#endif - callback.OnSizeChanged(this); - } - } - } - - 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); - _callbacks.Add(wr); - } - - internal (ResponsiveLayout Layout, Size Size, Layout? Result) ResolveLayout(ResponsiveLayout? layout, IEnumerable options) - { - layout ??= Layout; - var result = ResolveLayoutCore(layout, WindowSize.Width, options); - - return (layout, WindowSize, result); + return new(layout, size, result); } internal static Layout? ResolveLayoutCore(ResponsiveLayout layout, double width, IEnumerable options) { + // note: Tests call this function, so keep this pure and stateless. + // ResponsiveView and ResponsiveExtension calls are routed from ResolveLayout. + return options .Concat(new Layout[] { (Layout)int.MaxValue }) // used to get the +inf for the last one's upper-boundary .ZipSkipOne() @@ -215,22 +141,13 @@ internal void Register(IResponsiveCallback host) double GetThreshold(Layout x) => x switch { - UI.Layout.Narrowest => layout.Narrowest, - UI.Layout.Narrow => layout.Narrow, - UI.Layout.Normal => layout.Normal, - UI.Layout.Wide => layout.Wide, - UI.Layout.Widest => layout.Widest, + Layout.Narrowest => layout.Narrowest, + Layout.Narrow => layout.Narrow, + Layout.Normal => layout.Normal, + Layout.Wide => layout.Wide, + Layout.Widest => layout.Widest, _ => double.PositiveInfinity, }; } - - internal static IDisposable UsingDebuggableInstance() - { - UseDebuggableInstance = true; - - return Disposable.Create(() => UseDebuggableInstance = false); - } - - internal static void SetDebugSize(Size size) => _debugInstance.OnWindowSizeChanged(size); } diff --git a/src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs b/src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs index 08e772658..3937776c6 100644 --- a/src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs +++ b/src/Uno.Toolkit.UI/Helpers/VisualTreeHelperEx.cs @@ -260,16 +260,20 @@ static IEnumerable GetDetails(object x) #region Toolkit Control Details if (x is ResponsiveView rv) { - yield return $"Responsive: {FormatSize(rv.LastResolved.Size)}@{rv.LastResolved.Layout}->{rv.LastResolved.Result}"; + yield return rv.LastResolved is { } + ? $"Responsive: {FormatSize(rv.LastResolved.Size)}@{rv.LastResolved.Layout}->{rv.LastResolved.Result}" + : "Responsive: unresolved"; } -#if DEBUG +#if !WINDOWS_UWP if (ResponsiveExtension.TrackedInstances.Where(y => y.Owner.Target == x).ToArray() is { Length: > 0 } instances) { foreach (var item in instances) { if (item.Extension.Target is ResponsiveExtension re) { - yield return $"{item.Property}@Responsive: {FormatSize(re.LastResolved.Size)}@{re.LastResolved.Layout}->{re.LastResolved.Result}\\{re.CurrentValue}"; + yield return re.LastResolved is { } + ? $"{item.Property}@Responsive: {FormatSize(re.LastResolved.Size)}@{re.LastResolved.Layout}->{re.LastResolved.Result}\\{re.CurrentValue}" + : $"{item.Property}@Responsive: unresolved"; } } } diff --git a/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.cs b/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.cs index c2228cee9..6cd30df89 100644 --- a/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.cs +++ b/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.cs @@ -1,10 +1,12 @@ -#if !WINDOWS_UWP -#define SUPPORTS_XAML_SERVICE_PROVIDER +#if HAS_UNO +#define UNO14502_WORKAROUND // https://github.com/unoplatform/uno/issues/14502 #endif +#if !WINDOWS_UWP using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.Extensions.Logging; using Windows.Foundation; using Uno.Extensions; @@ -22,21 +24,17 @@ namespace Uno.Toolkit.UI; -#if DEBUG -public partial class ResponsiveExtension // for debugging -{ - // Used by TreeGraph to obtain the ResponsiveExtension(s) declared on the Owner. - internal static List<(WeakReference Owner, string Property, WeakReference Extension)> TrackedInstances { get; } = new(); -} -#endif - /// /// A markup extension that updates a property based on the current window width. /// -public partial class ResponsiveExtension : MarkupExtension, IResponsiveCallback +public partial class ResponsiveExtension : MarkupExtension { private static readonly ILogger _logger = typeof(ResponsiveExtension).Log(); +#if UNO14502_WORKAROUND + private ResponsiveExtension _selfHardReference; +#endif + public object? Narrowest { get; set; } public object? Narrow { get; set; } public object? Normal { get; set; } @@ -45,63 +43,47 @@ public partial class ResponsiveExtension : MarkupExtension, IResponsiveCallback public ResponsiveLayout? Layout { get; set; } -#if SUPPORTS_XAML_SERVICE_PROVIDER - internal WeakReference? TargetWeakRef { get; private set; } - private Type? _propertyType; + private WeakReference? _targetWeakRef; private DependencyProperty? _targetProperty; -#endif + private Type? _propertyType; public Layout? CurrentLayout { get; private set; } internal object? CurrentValue { get; private set; } - internal (ResponsiveLayout Layout, Size Size, Layout? Result) LastResolved { get; private set; } + internal ResolvedLayout? LastResolved { get; private set; } public ResponsiveExtension() { +#if UNO14502_WORKAROUND + _selfHardReference = this; +#endif } -#if !SUPPORTS_XAML_SERVICE_PROVIDER - /// - protected override object? ProvideValue() - { - _logger.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 ResolveValue(); - } -#else /// protected override object? ProvideValue(IXamlServiceProvider serviceProvider) - { - BindToEvents(serviceProvider); - - return ResolveValue(); - } -#endif - -#if SUPPORTS_XAML_SERVICE_PROVIDER - private void BindToEvents(IXamlServiceProvider serviceProvider) { if (serviceProvider.GetService(typeof(IProvideValueTarget)) is IProvideValueTarget pvt && pvt.TargetObject is FrameworkElement target && pvt.TargetProperty is ProvideValueTargetProperty pvtp && pvtp.DeclaringType.FindDependencyProperty($"{pvtp.Name}Property") is DependencyProperty dp) { - TargetWeakRef = new WeakReference(target); + _targetWeakRef = new WeakReference(target); _targetProperty = dp; _propertyType = pvtp.Type; - // here, we need to bind to two events: - // 1. Window.SizeChanged for obvious reason - // 2. Control.Loaded because the initial value(result of ProvideValue) is resolved without the inherited .resources - // which may define a different DefaultResponsiveLayout resource somewhere along the visual tree, so we need to rectify that. - ResponsiveHelper.GetForCurrentView().Register(this); target.Loaded += OnTargetLoaded; -#if DEBUG - TrackedInstances.Add((TargetWeakRef, pvtp.Name, new WeakReference(this))); -#endif + TrackedInstances.Add((_targetWeakRef, pvtp.Name, new WeakReference(this))); + + // try to return a somewhat valid value for now + return _propertyType?.IsValueType == true + ? Activator.CreateInstance(_propertyType) + : default; } else { this.Log().Error($"Failed to register {nameof(ResponsiveExtension)}"); + + return default; } } @@ -109,29 +91,43 @@ private void OnTargetLoaded(object sender, RoutedEventArgs e) { if (TargetWeakRef is { IsAlive: true, Target: FrameworkElement target }) { - target.Loaded -= OnTargetLoaded; + if (target.XamlRoot is null) return; + + target.XamlRoot.Changed -= OnTargetXamlRootPropertyChanged; + target.XamlRoot.Changed += OnTargetXamlRootPropertyChanged; // Along the visual tree, we may have a DefaultResponsiveLayout defined in the resources which could cause a different value to be resolved. // But because in ProvideValue, the target has not been added to the visual tree yet, we cannot access the "full" .resources yet. // So we need to rectify that here. - UpdateBindingIfNeeded(forceApplyValue: true); + UpdateBinding(target.XamlRoot, forceApplyValue: true); } } -#endif - public void OnSizeChanged(ResponsiveHelper helper) => UpdateBindingIfNeeded(helper); + private void OnTargetXamlRootPropertyChanged(XamlRoot sender, XamlRootChangedEventArgs args) + { + if (sender.Size == LastResolved?.Size) return; - [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "platform-specific block...")] - private void UpdateBindingIfNeeded(ResponsiveHelper? helper = null, bool forceApplyValue = false) + UpdateBinding(sender); + } + + internal void ForceResponsiveSize(Size size) + { + var resolved = ResponsiveHelper.ResolveLayout(size, GetAppliedLayout(), GetAvailableLayoutOptions()); + UpdateBinding(resolved, forceApplyValue: true); + } + + private void UpdateBinding(XamlRoot root, bool forceApplyValue = false) { -#if SUPPORTS_XAML_SERVICE_PROVIDER - helper ??= ResponsiveHelper.GetForCurrentView(); + var resolved = ResponsiveHelper.ResolveLayout(root.Size, GetAppliedLayout(), GetAvailableLayoutOptions()); + UpdateBinding(resolved, forceApplyValue); + } - if (TargetWeakRef?.Target is FrameworkElement target && - _targetProperty is not null) + private void UpdateBinding(ResolvedLayout resolved, bool forceApplyValue = false) + { + if (forceApplyValue || CurrentLayout != resolved.Result) { - var resolved = helper.ResolveLayout(GetAppliedLayout(), GetAvailableLayoutOptions()); - if (forceApplyValue || CurrentLayout != resolved.Result) + if (TargetWeakRef?.Target is FrameworkElement target && + _targetProperty is not null) { var value = GetValueFor(resolved.Result); @@ -142,20 +138,6 @@ private void UpdateBindingIfNeeded(ResponsiveHelper? helper = null, bool forceAp LastResolved = resolved; } } -#endif - } - - private object? ResolveValue() - { - var helper = ResponsiveHelper.GetForCurrentView(); - var resolved = helper.ResolveLayout(GetAppliedLayout(), GetAvailableLayoutOptions()); - var value = GetValueFor(resolved.Result); - - CurrentValue = value; - CurrentLayout = resolved.Result; - LastResolved = resolved; - - return value; } private static object? XamlCastSafe(object value, Type type) @@ -186,12 +168,10 @@ private void UpdateBindingIfNeeded(ResponsiveHelper? helper = null, bool forceAp UI.Layout.Widest => Widest, _ => null, }; -#if SUPPORTS_XAML_SERVICE_PROVIDER if (value != null && _propertyType != null && value.GetType() != _propertyType) { value = XamlCastSafe(value, _propertyType); } -#endif return value; } @@ -207,8 +187,26 @@ private IEnumerable GetAvailableLayoutOptions() internal ResponsiveLayout? GetAppliedLayout() => Layout ?? -#if SUPPORTS_XAML_SERVICE_PROVIDER (TargetWeakRef?.Target as FrameworkElement)?.ResolveLocalResource(ResponsiveLayout.DefaultResourceKey) ?? -#endif Application.Current.ResolveLocalResource(ResponsiveLayout.DefaultResourceKey); } +public partial class ResponsiveExtension +{ + // Used by TreeGraph to obtain the ResponsiveExtension(s) associated with the owner. + internal static List<(WeakReference Owner, string Property, WeakReference Extension)> TrackedInstances { get; } = new(); + + internal WeakReference? TargetWeakRef => _targetWeakRef; + + internal static ResponsiveExtension[] GetAllInstancesFor(DependencyObject owner) => TrackedInstances + .Where(x => x.Owner?.Target as DependencyObject == owner) + .Select(x => x.Extension.Target) + .OfType() + .ToArray(); + + internal static ResponsiveExtension? GetInstanceFor(DependencyObject owner, string property) => TrackedInstances + .Where(x => x.Owner?.Target as DependencyObject == owner && x.Property == property) + .Select(x => x.Extension.Target) + .OfType() + .FirstOrDefault(); +} +#endif diff --git a/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.not-supported.cs b/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.not-supported.cs new file mode 100644 index 000000000..90ca00924 --- /dev/null +++ b/src/Uno.Toolkit.UI/Markup/ResponsiveExtension.not-supported.cs @@ -0,0 +1,32 @@ +#if WINDOWS_UWP +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Uno.Extensions; +using Uno.Logging; + +#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; + +public partial class ResponsiveExtension : MarkupExtension +{ + private static readonly ILogger _logger = typeof(ResponsiveExtension).Log(); + + /// + protected override object? ProvideValue() + { + _logger.WarnIfEnabled(() => "This xaml markup extension is not supported on UWP. Consider upgrading to WinUI."); + return null; + } +} +#endif