From 8f6289ef1686f4850b564ddc050a61ec105913ce Mon Sep 17 00:00:00 2001 From: xiaoy312 Date: Wed, 13 Dec 2023 19:33:26 -0500 Subject: [PATCH] fix(responsive): layout breakpoint calculation --- doc/controls/ResponsiveView.md | 48 ++++++++- doc/helpers/responsive-extension.md | 98 ++++++++++++++----- .../Tests/ResponsiveHelperTests.cs | 66 +++++++++++++ .../Tests/ResponsiveViewTests.cs | 2 +- .../Helpers/ResponsiveHelper.cs | 29 ++++-- 5 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs diff --git a/doc/controls/ResponsiveView.md b/doc/controls/ResponsiveView.md index ef7079bdd..4985a914a 100644 --- a/doc/controls/ResponsiveView.md +++ b/doc/controls/ResponsiveView.md @@ -26,9 +26,6 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) } ``` -## Inheritance -Object → DependencyObject → UIElement → FrameworkElement → Control → ContentControl - ## Properties | Property | Type | Description | | ----------------- | ---------------- | ------------------------------------------------------- | @@ -42,7 +39,7 @@ Object → DependencyObject → UIElement → FrameworkElement ͛ ### ResponsiveLayout Provides the ability to override the breakpoint for each screen size: `Narrowest`, `Narrow`, `Normal`, `Wide`, and `Widest`. -### Properties +#### Properties | Property | Type | Description | | ---------- | ------ | ---------------------- | | Narrowest | double | Default value is 150. | @@ -51,6 +48,49 @@ Provides the ability to override the breakpoint for each screen size: `Narrowest | Wide | double | Default value is 800. | | Widest | double | Default value is 1080. | +#### Resolution Logics +The layouts whose value(ResponsiveExtension) or template(ResponsiveView) is not provided are first discarded. From the remaining layouts, we look for the first layout whose breakpoint at met by the current screen width. If none are found, the first layout is return regardless of its breakpoint. + +Below are the selected layout at different screen width if all layouts are provided: + +Width|Layout +-|- +149|Narrowest +150(Narrowest)|Narrowest +151|Narrowest +299|Narrowest +300(Narrow)|Narrow +301|Narrow +599|Narrow +600(Normal)|Normal +601|Normal +799|Normal +800(Wide)|Wide +801|Wide +1079|Wide +1080(Widest)|Widest +1081|Widest + +Here are the selected layout at different screen width if only `Narrow` and `Wide` are provided: + +Width|Layout +-|- +149|Narrow +150(~~Narrowest~~)|Narrow +151|Narrow +299|Narrow +300(Narrow)|Narrow +301|Narrow +599|Narrow +600(~~Normal~~)|Narrow +601|Narrow +799|Narrow +800(Wide)|Wide +801|Wide +1079|Wide +1080(~~Widest~~)|Wide +1081|Wide + ## Usage > [!TIP] diff --git a/doc/helpers/responsive-extension.md b/doc/helpers/responsive-extension.md index f6c6d7c33..da30b3089 100644 --- a/doc/helpers/responsive-extension.md +++ b/doc/helpers/responsive-extension.md @@ -1,37 +1,12 @@ --- uid: Toolkit.Helpers.ResponsiveExtension --- + # 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. -### Inheritance -Object → MarkupExtension → ResponsiveExtension - -## 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 **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`: @@ -49,6 +24,7 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) 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. ```xml @@ -65,6 +41,74 @@ You can workaround this by declaring the values as resources and using {StaticRe ``` + +## 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. | + +#### Resolution Logics +The layouts whose value(ResponsiveExtension) or template(ResponsiveView) is not provided are first discarded. From the remaining layouts, we look for the first layout whose breakpoint at met by the current screen width. If none are found, the first layout is return regardless of its breakpoint. + +Below are the selected layout at different screen width if all layouts are provided: + +Width|Layout +-|- +149|Narrowest +150(Narrowest)|Narrowest +151|Narrowest +299|Narrowest +300(Narrow)|Narrow +301|Narrow +599|Narrow +600(Normal)|Normal +601|Normal +799|Normal +800(Wide)|Wide +801|Wide +1079|Wide +1080(Widest)|Widest +1081|Widest + +Here are the selected layout at different screen width if only `Narrow` and `Wide` are provided: + +Width|Layout +-|- +149|Narrow +150(~~Narrowest~~)|Narrow +151|Narrow +299|Narrow +300(Narrow)|Narrow +301|Narrow +599|Narrow +600(~~Normal~~)|Narrow +601|Narrow +799|Narrow +800(Wide)|Wide +801|Wide +1079|Wide +1080(~~Widest~~)|Wide +1081|Wide + + ## Usage > [!TIP] @@ -77,6 +121,8 @@ xmlns:utu="using:Uno.Toolkit.UI" ``` + + ### Custom thresholds ```xml xmlns:utu="using:Uno.Toolkit.UI" diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs new file mode 100644 index 000000000..f94a16a90 --- /dev/null +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveHelperTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.Toolkit.UI; + +namespace Uno.Toolkit.RuntimeTests.Tests; + +[TestClass] +public class ResponsiveHelperTests +{ + private readonly static ResponsiveLayout DefaultLayout = ResponsiveLayout.Create(150, 300, 600, 800, 1080); + + // note: not to scale; '[' = inclusive to the right + // 0 150 300 600 800 1080 ... + // Narrowest(also) - Narrowest [ Narrow [ Normal [ Wide [ Widest - // full layout + // Normal - - + + [TestMethod] + public void When_Resolving_AllLayout() + { + var layout = DefaultLayout; + var options = Enum.GetValues(); + + Assert.AreEqual(Layout.Narrowest, ResponsiveHelper.ResolveLayoutCore(layout, 149, options), "149"); + Assert.AreEqual(Layout.Narrowest, ResponsiveHelper.ResolveLayoutCore(layout, 150, options), "150"); // breakpoint=Narrowest + Assert.AreEqual(Layout.Narrowest, ResponsiveHelper.ResolveLayoutCore(layout, 151, options), "151"); + Assert.AreEqual(Layout.Narrowest, ResponsiveHelper.ResolveLayoutCore(layout, 299, options), "299"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 300, options), "300"); // breakpoint=Narrow + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 301, options), "301"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 599, options), "599"); + Assert.AreEqual(Layout.Normal, ResponsiveHelper.ResolveLayoutCore(layout, 600, options), "600"); // breakpoint=Normal + Assert.AreEqual(Layout.Normal, ResponsiveHelper.ResolveLayoutCore(layout, 601, options), "601"); + Assert.AreEqual(Layout.Normal, ResponsiveHelper.ResolveLayoutCore(layout, 799, options), "799"); + Assert.AreEqual(Layout.Wide, ResponsiveHelper.ResolveLayoutCore(layout, 800, options), "800"); // breakpoint=Wide + Assert.AreEqual(Layout.Wide, ResponsiveHelper.ResolveLayoutCore(layout, 801, options), "801"); + Assert.AreEqual(Layout.Wide, ResponsiveHelper.ResolveLayoutCore(layout, 1079, options), "1079"); + Assert.AreEqual(Layout.Widest, ResponsiveHelper.ResolveLayoutCore(layout, 1080, options), "1080"); // breakpoint=Widest + Assert.AreEqual(Layout.Widest, ResponsiveHelper.ResolveLayoutCore(layout, 1081, options), "1081"); + } + + [TestMethod] + public void When_Resolving_PartialLayout() + { + var layout = DefaultLayout; + var options = new[] { Layout.Narrow, Layout.Wide, Layout.Widest }; + + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 149, options), "149"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 150, options), "150"); // breakpoint=Narrowest (unavailable) + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 151, options), "151"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 299, options), "299"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 300, options), "300"); // breakpoint=Narrow + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 301, options), "301"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 599, options), "599"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 600, options), "600"); // breakpoint=Normal (unavailable) + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 601, options), "601"); + Assert.AreEqual(Layout.Narrow, ResponsiveHelper.ResolveLayoutCore(layout, 799, options), "799"); + Assert.AreEqual(Layout.Wide, ResponsiveHelper.ResolveLayoutCore(layout, 800, options), "800"); // breakpoint=Wide + Assert.AreEqual(Layout.Wide, ResponsiveHelper.ResolveLayoutCore(layout, 801, options), "801"); + Assert.AreEqual(Layout.Wide, ResponsiveHelper.ResolveLayoutCore(layout, 1079, options), "1079"); + Assert.AreEqual(Layout.Widest, ResponsiveHelper.ResolveLayoutCore(layout, 1080, options), "1080"); // breakpoint=Widest + Assert.AreEqual(Layout.Widest, ResponsiveHelper.ResolveLayoutCore(layout, 1081, options), "1081"); + } +} diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs index bec047369..f1412d8a2 100644 --- a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs @@ -54,7 +54,7 @@ public async Task ResponsiveView_NormalContent_Rectangle() { using (ResponsiveHelper.UsingDebuggableInstance()) { - ResponsiveHelper.SetDebugSize(new Size(599, 400)); + ResponsiveHelper.SetDebugSize(new Size(600, 400)); var host = XamlHelper.LoadXaml(""" diff --git a/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs index 7f07d2dac..1cd439dd7 100644 --- a/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs +++ b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs @@ -3,6 +3,7 @@ #endif using System; +using System.Linq; using System.Collections.Generic; using Windows.Foundation; using Uno.Disposables; @@ -109,6 +110,8 @@ public static ResponsiveLayout Create(double narrowest, double narrow, double no Wide = wide, Widest = widest, }; + + public IEnumerable GetBreakpoints() => new[] { Narrowest, Narrow, Normal, Wide, Widest }; public override string ToString() => "[" + string.Join(",", Narrowest, Narrow, Normal, Wide, Widest) + "]"; } @@ -191,21 +194,35 @@ internal void Register(IResponsiveCallback host) internal (ResponsiveLayout Layout, Size Size, Layout? Result) ResolveLayout(ResponsiveLayout? layout, IEnumerable options) { layout ??= Layout; - var result = - options.FirstOrNull(SatisfyLayoutThreshold) ?? - options.LastOrNull(); + var result = ResolveLayoutCore(layout, WindowSize.Width, options); return (layout, WindowSize, result); + } - bool SatisfyLayoutThreshold(Layout x) => x switch + internal static Layout? ResolveLayoutCore(ResponsiveLayout layout, double width, IEnumerable options) + { + return options + .Concat(new Layout[] { (Layout)int.MaxValue }) // used to get the +inf for the last one's upper-boundary + .ZipSkipOne() + .Select(x => new + { + Layout = x.Previous, + InclusiveLBound = GetThreshold(x.Previous), + ExclusiveUBound = GetThreshold(x.Current), + }) + .FirstOrDefault(x => x.InclusiveLBound <= width && width < x.ExclusiveUBound) + ?.Layout ?? options.FirstOrNull(); + + 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, - _ => double.NaN, - } >= WindowSize.Width; + + _ => double.PositiveInfinity, + }; } internal static IDisposable UsingDebuggableInstance()