diff --git a/doc/controls/ResponsiveView.md b/doc/controls/ResponsiveView.md new file mode 100644 index 000000000..2190efd82 --- /dev/null +++ b/doc/controls/ResponsiveView.md @@ -0,0 +1,133 @@ +--- +uid: Toolkit.Controls.ResponsiveView +--- +# ResponsiveView + +## Summary + +The `ResponsiveView` provides the ability to display different content based on screen size, making it easy to adapt to various devices. + +## Remarks + +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 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`. + +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); +} +``` + +## Inheritance +Object → DependencyObject → UIElement → FrameworkElement → Control → ContentControl + +## Properties +| Property | Type | Description | +| ----------------- | ---------------- | ------------------------------------------------------- | +| NarrowestTemplate | DataTemplate | Template to be displayed on the narrowest screen size. | +| NarrowTemplate | DataTemplate | Template to be displayed on a narrow screen size. | +| NormalTemplate | DataTemplate | Template to be displayed on a normal screen size. | +| WideTemplate | DataTemplate | Template to be displayed on a wide screen size. | +| WidestTemplate | DataTemplate | Template to be displayed on the widest screen size. | +| ResponsiveLayout | ResponsiveLayout | Overrides the screen size threshold/breakpoints. | + +### ResponsiveLayout +Provides the ability to override the MaxWidth for each screen size: `Narrowest`, `Narrow`, `Normal`, `Wide`, and `Widest`. + +### 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. | + +## Usage + +```xml +xmlns:utu="using:Uno.Toolkit.UI" +... + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Using `ResponsiveLayout` to customize the screen size breakpoints. + +```xml +xmlns:utu="using:Uno.Toolkit.UI" +... + + + + 200 + 350 + 800 + 1200 + 1500 + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` \ No newline at end of file diff --git a/doc/toc.yml b/doc/toc.yml index 257cc6901..24b0836f3 100644 --- a/doc/toc.yml +++ b/doc/toc.yml @@ -38,6 +38,8 @@ href: controls/LoadingView.md - name: NavigationBar href: controls/NavigationBar.md + - name: ResponsiveView + href: controls/ResponsiveView.md - name: SafeArea href: controls/SafeArea.md - name: ShadowContainer 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 80a00535d..fcfe1abff 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,6 +98,8 @@ 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/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ResponsiveViewSamplePage.xaml b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ResponsiveViewSamplePage.xaml new file mode 100644 index 000000000..653022291 --- /dev/null +++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ResponsiveViewSamplePage.xaml @@ -0,0 +1,165 @@ + + + + + + + + + #67E5AD + #7A67F8 + #F85977 + #159BFF + + + + + 200 + 350 + 800 + 1200 + 1500 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ResponsiveViewSamplePage.xaml.cs b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ResponsiveViewSamplePage.xaml.cs new file mode 100644 index 000000000..08bb58614 --- /dev/null +++ b/samples/Uno.Toolkit.Samples/Uno.Toolkit.Samples.Shared/Content/Controls/ResponsiveViewSamplePage.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.Controls +{ + [SamplePage(SampleCategory.Controls, "ResponsiveView")] + public sealed partial class ResponsiveViewSamplePage : Page + { + public ResponsiveViewSamplePage() + { + 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 12f87b4e6..344949c4e 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 @@ -25,6 +25,9 @@ AutoLayoutPage.xaml + + ResponsiveViewSamplePage.xaml + CardSamplePage.xaml @@ -221,6 +224,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs new file mode 100644 index 000000000..bec047369 --- /dev/null +++ b/src/Uno.Toolkit.RuntimeTests/Tests/ResponsiveViewTests.cs @@ -0,0 +1,204 @@ +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.UI.RuntimeTests; +using Uno.Toolkit.RuntimeTests.Helpers; +using Windows.Foundation; +using Uno.Toolkit.UI; + +#if IS_WINUI +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Shapes; +#else +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Shapes; +#endif + +namespace Uno.Toolkit.RuntimeTests.Tests; + +[TestClass] +[RunsOnUIThread] +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); + } + } + + [TestMethod] + public async Task ResponsiveView_NormalContent_Rectangle() + { + using (ResponsiveHelper.UsingDebuggableInstance()) + { + ResponsiveHelper.SetDebugSize(new Size(599, 400)); + + var host = XamlHelper.LoadXaml(""" + + + + + + + + + + + + + + + + + + """); + + await UnitTestUIContentHelperEx.SetContentAndWait(host); + + 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()); + } + } + + [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()); + } + } + + [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()); + } + } +} diff --git a/src/Uno.Toolkit.UI/Controls/ResponsiveView/ResponsiveView.cs b/src/Uno.Toolkit.UI/Controls/ResponsiveView/ResponsiveView.cs new file mode 100644 index 000000000..b4fd23fa5 --- /dev/null +++ b/src/Uno.Toolkit.UI/Controls/ResponsiveView/ResponsiveView.cs @@ -0,0 +1,163 @@ +using System.Linq; +using Windows.Foundation; +#if IS_WINUI +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +#else +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +#endif + +namespace Uno.Toolkit.UI; + +public partial class ResponsiveView : ContentControl, IResponsiveCallback +{ + #region DependencyProperties + + #region Narrowest DP + public DataTemplate NarrowestTemplate + { + get { return (DataTemplate)GetValue(NarrowestTemplateProperty); } + set { SetValue(NarrowestTemplateProperty, value); } + } + + public static readonly DependencyProperty NarrowestTemplateProperty = + DependencyProperty.Register("NarrowestTemplate", typeof(DataTemplate), typeof(ResponsiveView), new PropertyMetadata(null, OnNarrowestTemplateChanged)); + + private static void OnNarrowestTemplateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + => OnResponsiveTemplateChanged(d, e); + + #endregion + + #region Narrow DP + public DataTemplate NarrowTemplate + { + get { return (DataTemplate)GetValue(NarrowTemplateProperty); } + set { SetValue(NarrowTemplateProperty, value); } + } + + public static readonly DependencyProperty NarrowTemplateProperty = + DependencyProperty.Register("NarrowTemplate", typeof(DataTemplate), typeof(ResponsiveView), new PropertyMetadata(null, OnNarrowTemplateChanged)); + + private static void OnNarrowTemplateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + => OnResponsiveTemplateChanged(d, e); + #endregion + + #region Normal DP + public DataTemplate NormalTemplate + { + get { return (DataTemplate)GetValue(NormalTemplateProperty); } + set { SetValue(NormalTemplateProperty, value); } + } + + public static readonly DependencyProperty NormalTemplateProperty = + DependencyProperty.Register("NormalTemplate", typeof(DataTemplate), typeof(ResponsiveView), new PropertyMetadata(null, OnNormalTemplateChanged)); + + private static void OnNormalTemplateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + => OnResponsiveTemplateChanged(d, e); + #endregion + + #region Wide DP + public DataTemplate WideTemplate + { + get { return (DataTemplate)GetValue(WideTemplateProperty); } + set { SetValue(WideTemplateProperty, value); } + } + + public static readonly DependencyProperty WideTemplateProperty = + DependencyProperty.Register("WideTemplate", typeof(DataTemplate), typeof(ResponsiveView), new PropertyMetadata(null, OnWideTemplateChanged)); + + private static void OnWideTemplateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + => OnResponsiveTemplateChanged(d, e); + #endregion + + #region Widest DP + public DataTemplate WidestTemplate + { + get { return (DataTemplate)GetValue(WidestTemplateProperty); } + set { SetValue(WidestTemplateProperty, value); } + } + + public static readonly DependencyProperty WidestTemplateProperty = + DependencyProperty.Register("WidestTemplate", typeof(DataTemplate), typeof(ResponsiveView), new PropertyMetadata(null, OnWidestTemplateChanged)); + + private static void OnWidestTemplateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + => OnResponsiveTemplateChanged(d, e); + #endregion + + #region ResponsiveLayout DP + public static DependencyProperty ResponsiveLayoutProperty { get; } = DependencyProperty.Register( + nameof(ResponsiveLayout), + typeof(ResponsiveLayout), + typeof(ResponsiveView), + new PropertyMetadata(default)); + + public ResponsiveLayout ResponsiveLayout + { + get => (ResponsiveLayout)GetValue(ResponsiveLayoutProperty); + set => SetValue(ResponsiveLayoutProperty, value); + } + #endregion + + private static void OnResponsiveTemplateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ResponsiveView { IsLoaded: true } view) + { + var dataTemplate = view.GetInitialValue(); + view.Content = dataTemplate?.LoadContent() as UIElement; + } + } + #endregion + + private DataTemplate? _currentContent; + + public ResponsiveView() + { + this.DefaultStyleKey = typeof(ResponsiveView); + + ResponsiveHelper.GetForCurrentView().Register(this); + + Loaded += ResponsiveView_Loaded; + } + + private void ResponsiveView_Loaded(object sender, RoutedEventArgs e) + { + _currentContent = GetInitialValue(); + + Content = _currentContent?.LoadContent() as UIElement; + } + + private DataTemplate? GetInitialValue() + { + var helper = ResponsiveHelper.GetForCurrentView(); + + return GetValueForSize(helper.WindowSize, ResponsiveLayout ?? helper.Layout); + } + + private DataTemplate? GetValueForSize(Size size, ResponsiveLayout layout) + { + var defs = new (double MinWidth, DataTemplate? Value)?[] + { + (layout.Narrowest, NarrowestTemplate), + (layout.Narrow, NarrowTemplate), + (layout.Normal, NormalTemplate), + (layout.Wide, WideTemplate), + (layout.Widest, WidestTemplate), + }.Where(x => x?.Value != null).ToArray(); + + var match = defs.FirstOrDefault(y => y?.MinWidth >= size.Width) ?? defs.LastOrDefault(); + + return match?.Value; + } + + public void OnSizeChanged(Size size, ResponsiveLayout layout) + { + var dataTemplate = GetValueForSize(size, ResponsiveLayout ?? layout); + + if (dataTemplate != _currentContent) + { + _currentContent = dataTemplate; + Content = dataTemplate?.LoadContent() as UIElement; + } + } +} diff --git a/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs new file mode 100644 index 000000000..925d53034 --- /dev/null +++ b/src/Uno.Toolkit.UI/Helpers/ResponsiveHelper.cs @@ -0,0 +1,159 @@ +using System.Collections.Generic; +using System; +using Windows.Foundation; +using Uno.Disposables; + +#if IS_WINUI +using Microsoft.UI.Xaml; +#else +using Windows.UI.Xaml; +using Windows.UI.Core; +#endif + +namespace Uno.Toolkit.UI; + +internal interface IResponsiveCallback +{ + void OnSizeChanged(Size size, ResponsiveLayout layout); +} + +public partial class ResponsiveLayout : DependencyObject +{ + #region DependencyProperty: Narrowest + + public static DependencyProperty NarrowestProperty { get; } = DependencyProperty.Register( + nameof(Narrowest), + typeof(double), + typeof(ResponsiveLayout), + new PropertyMetadata(0d)); + + public double Narrowest + { + get => (double)GetValue(NarrowestProperty); + set => SetValue(NarrowestProperty, value); + } + + #endregion + #region DependencyProperty: Narrow + + public static DependencyProperty NarrowProperty { get; } = DependencyProperty.Register( + nameof(Narrow), + typeof(double), + typeof(ResponsiveLayout), + new PropertyMetadata(0d)); + + public double Narrow + { + get => (double)GetValue(NarrowProperty); + set => SetValue(NarrowProperty, value); + } + + #endregion + #region DependencyProperty: Normal + + public static DependencyProperty NormalProperty { get; } = DependencyProperty.Register( + nameof(Normal), + typeof(double), + typeof(ResponsiveLayout), + new PropertyMetadata(0d)); + + public double Normal + { + get => (double)GetValue(NormalProperty); + set => SetValue(NormalProperty, value); + } + + #endregion + #region DependencyProperty: Wide + + public static DependencyProperty WideProperty { get; } = DependencyProperty.Register( + nameof(Wide), + typeof(double), + typeof(ResponsiveLayout), + new PropertyMetadata(0d)); + + public double Wide + { + get => (double)GetValue(WideProperty); + set => SetValue(WideProperty, value); + } + + #endregion + #region DependencyProperty: Widest + + public static DependencyProperty WidestProperty { get; } = DependencyProperty.Register( + nameof(Widest), + typeof(double), + typeof(ResponsiveLayout), + new PropertyMetadata(0d)); + + public double Widest + { + get => (double)GetValue(WidestProperty); + set => SetValue(WidestProperty, value); + } + + #endregion + + public static ResponsiveLayout Create(double narrowest, double narrow, double normal, double wide, double widest) => new() + { + Narrowest = narrowest, + Narrow = narrow, + Normal = normal, + Wide = wide, + Widest = 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; + + 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; + + private ResponsiveHelper() { } + + public void HookupEvent(Window window) + { + WindowSize = new Size(window.Bounds.Width, window.Bounds.Height); + + 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); + + foreach (var reference in _references.ToArray()) + { + if (reference.IsAlive && reference.Target is IResponsiveCallback callback) + { + callback.OnSizeChanged(WindowSize, Layout); + } + } + } + + internal void Register(IResponsiveCallback host) + { + var wr = new WeakReference(host); + _references.Add(wr); + } + + internal static IDisposable UsingDebuggableInstance() + { + UseDebuggableInstance = true; + + return Disposable.Create(() => UseDebuggableInstance = false); + } +}