diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index 27905a44d9..ec7a11067c 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -57,6 +57,7 @@ + diff --git a/samples/ControlCatalog/Pages/SplitViewPage.xaml b/samples/ControlCatalog/Pages/SplitViewPage.xaml new file mode 100644 index 0000000000..7e629db2da --- /dev/null +++ b/samples/ControlCatalog/Pages/SplitViewPage.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + Inline + CompactInline + Overlay + CompactOverlay + + + + + SystemControlBackgroundChromeMediumLowBrush + Red + Blue + Green + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/SplitViewPage.xaml.cs b/samples/ControlCatalog/Pages/SplitViewPage.xaml.cs new file mode 100644 index 0000000000..cbf217c94a --- /dev/null +++ b/samples/ControlCatalog/Pages/SplitViewPage.xaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using ControlCatalog.ViewModels; + +namespace ControlCatalog.Pages +{ + public class SplitViewPage : UserControl + { + public SplitViewPage() + { + this.InitializeComponent(); + DataContext = new SplitViewPageViewModel(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/ControlCatalog/ViewModels/SplitViewPageViewModel.cs b/samples/ControlCatalog/ViewModels/SplitViewPageViewModel.cs new file mode 100644 index 0000000000..f27f605a8b --- /dev/null +++ b/samples/ControlCatalog/ViewModels/SplitViewPageViewModel.cs @@ -0,0 +1,46 @@ +using System; +using Avalonia.Controls; +using ReactiveUI; + +namespace ControlCatalog.ViewModels +{ + public class SplitViewPageViewModel : ReactiveObject + { + private bool _isLeft = true; + private int _displayMode = 3; //CompactOverlay + + public bool IsLeft + { + get => _isLeft; + set + { + this.RaiseAndSetIfChanged(ref _isLeft, value); + this.RaisePropertyChanged(nameof(PanePlacement)); + } + } + + public int DisplayMode + { + get => _displayMode; + set + { + this.RaiseAndSetIfChanged(ref _displayMode, value); + this.RaisePropertyChanged(nameof(CurrentDisplayMode)); + } + } + + public SplitViewPanePlacement PanePlacement => _isLeft ? SplitViewPanePlacement.Left : SplitViewPanePlacement.Right; + + public SplitViewDisplayMode CurrentDisplayMode + { + get + { + if (Enum.IsDefined(typeof(SplitViewDisplayMode), _displayMode)) + { + return (SplitViewDisplayMode)_displayMode; + } + return SplitViewDisplayMode.CompactOverlay; + } + } + } +} diff --git a/src/Avalonia.Animation/Easing/Easing.cs b/src/Avalonia.Animation/Easing/Easing.cs index 5b0dea6c60..e006459652 100644 --- a/src/Avalonia.Animation/Easing/Easing.cs +++ b/src/Avalonia.Animation/Easing/Easing.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Globalization; using System.Linq; namespace Avalonia.Animation.Easings @@ -25,6 +26,11 @@ namespace Avalonia.Animation.Easings /// Returns the instance of the parsed type. public static Easing Parse(string e) { + if (e.Contains(',')) + { + return new SplineEasing(KeySpline.Parse(e, CultureInfo.InvariantCulture)); + } + if (_easingTypes == null) { _easingTypes = new Dictionary(); diff --git a/src/Avalonia.Animation/Easing/SplineEasing.cs b/src/Avalonia.Animation/Easing/SplineEasing.cs new file mode 100644 index 0000000000..975fcc4746 --- /dev/null +++ b/src/Avalonia.Animation/Easing/SplineEasing.cs @@ -0,0 +1,85 @@ +namespace Avalonia.Animation.Easings +{ + /// + /// Eases a value + /// using a user-defined cubic bezier curve. + /// Good for custom easing functions that doesn't quite + /// fit with the built-in ones. + /// + public class SplineEasing : Easing + { + /// + /// X coordinate of the first control point + /// + public double X1 + { + get => _internalKeySpline.ControlPointX1; + set + { + _internalKeySpline.ControlPointX1 = value; + } + } + + /// + /// Y coordinate of the first control point + /// + public double Y1 + { + get => _internalKeySpline.ControlPointY1; + set + { + _internalKeySpline.ControlPointY1 = value; + } + } + + /// + /// X coordinate of the second control point + /// + public double X2 + { + get => _internalKeySpline.ControlPointX2; + set + { + _internalKeySpline.ControlPointX2 = value; + } + } + + /// + /// Y coordinate of the second control point + /// + public double Y2 + { + get => _internalKeySpline.ControlPointY2; + set + { + _internalKeySpline.ControlPointY2 = value; + } + } + + private readonly KeySpline _internalKeySpline; + + public SplineEasing(double x1 = 0d, double y1 = 0d, double x2 = 1d, double y2 = 1d) + { + _internalKeySpline = new KeySpline(); + + this.X1 = x1; + this.Y1 = y1; + this.X2 = x2; + this.Y1 = y2; + } + + public SplineEasing(KeySpline keySpline) + { + _internalKeySpline = keySpline; + } + + public SplineEasing() + { + _internalKeySpline = new KeySpline(); + } + + /// + public override double Ease(double progress) => + _internalKeySpline.GetSplineProgress(progress); + } +} diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index 5a4f7a15a3..a6e9769186 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -81,7 +81,10 @@ namespace Avalonia.Animation /// A with the appropriate values set public static KeySpline Parse(string value, CultureInfo culture) { - using (var tokenizer = new StringTokenizer((string)value, CultureInfo.InvariantCulture, exceptionMessage: "Invalid KeySpline.")) + if (culture is null) + culture = CultureInfo.InvariantCulture; + + using (var tokenizer = new StringTokenizer((string)value, culture, exceptionMessage: $"Invalid KeySpline string: \"{value}\".")) { return new KeySpline(tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble()); } @@ -98,6 +101,7 @@ namespace Avalonia.Animation if (IsValidXValue(value)) { _controlPointX1 = value; + _isDirty = true; } else { @@ -112,7 +116,11 @@ namespace Avalonia.Animation public double ControlPointY1 { get => _controlPointY1; - set => _controlPointY1 = value; + set + { + _controlPointY1 = value; + _isDirty = true; + } } /// @@ -126,6 +134,7 @@ namespace Avalonia.Animation if (IsValidXValue(value)) { _controlPointX2 = value; + _isDirty = true; } else { @@ -140,7 +149,11 @@ namespace Avalonia.Animation public double ControlPointY2 { get => _controlPointY2; - set => _controlPointY2 = value; + set + { + _controlPointY2 = value; + _isDirty = true; + } } /// @@ -330,20 +343,4 @@ namespace Avalonia.Animation } } } - - /// - /// Converts string values to values - /// - public class KeySplineTypeConverter : TypeConverter - { - public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) - { - return sourceType == typeof(string); - } - - public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) - { - return KeySpline.Parse((string)value, culture); - } - } } diff --git a/src/Avalonia.Animation/KeySplineTypeConverter.cs b/src/Avalonia.Animation/KeySplineTypeConverter.cs new file mode 100644 index 0000000000..cd7427a37d --- /dev/null +++ b/src/Avalonia.Animation/KeySplineTypeConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +// Ported from WPF open-source code. +// https://github.com/dotnet/wpf/blob/ae1790531c3b993b56eba8b1f0dd395a3ed7de75/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Animation/KeySpline.cs + +namespace Avalonia.Animation +{ + /// + /// Converts string values to values + /// + public class KeySplineTypeConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + return KeySpline.Parse((string)value, culture); + } + } +} diff --git a/src/Avalonia.Controls/SplitView.cs b/src/Avalonia.Controls/SplitView.cs new file mode 100644 index 0000000000..fc3ff51f24 --- /dev/null +++ b/src/Avalonia.Controls/SplitView.cs @@ -0,0 +1,487 @@ +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Metadata; +using Avalonia.Platform; +using Avalonia.VisualTree; +using System; +using System.Reactive.Disposables; + +namespace Avalonia.Controls +{ + /// + /// Defines constants for how the SplitView Pane should display + /// + public enum SplitViewDisplayMode + { + /// + /// Pane is displayed next to content, and does not auto collapse + /// when tapped outside + /// + Inline, + /// + /// Pane is displayed next to content. When collapsed, pane is still + /// visible according to CompactPaneLength. Pane does not auto collapse + /// when tapped outside + /// + CompactInline, + /// + /// Pane is displayed above content. Pane collapses when tapped outside + /// + Overlay, + /// + /// Pane is displayed above content. When collapsed, pane is still + /// visible according to CompactPaneLength. Pane collapses when tapped outside + /// + CompactOverlay + } + + /// + /// Defines constants for where the Pane should appear + /// + public enum SplitViewPanePlacement + { + Left, + Right + } + + public class SplitViewTemplateSettings : AvaloniaObject + { + internal SplitViewTemplateSettings() { } + + public static readonly StyledProperty ClosedPaneWidthProperty = + AvaloniaProperty.Register(nameof(ClosedPaneWidth), 0d); + + public static readonly StyledProperty PaneColumnGridLengthProperty = + AvaloniaProperty.Register(nameof(PaneColumnGridLength)); + + public double ClosedPaneWidth + { + get => GetValue(ClosedPaneWidthProperty); + internal set => SetValue(ClosedPaneWidthProperty, value); + } + + public GridLength PaneColumnGridLength + { + get => GetValue(PaneColumnGridLengthProperty); + internal set => SetValue(PaneColumnGridLengthProperty, value); + } + } + + /// + /// A control with two views: A collapsible pane and an area for content + /// + public class SplitView : TemplatedControl + { + /* + Pseudo classes & combos + :open / :closed + :compactoverlay :compactinline :overlay :inline + :left :right + */ + + /// + /// Defines the property + /// + public static readonly StyledProperty ContentProperty = + AvaloniaProperty.Register(nameof(Content)); + + /// + /// Defines the property + /// + public static readonly StyledProperty CompactPaneLengthProperty = + AvaloniaProperty.Register(nameof(CompactPaneLength), defaultValue: 48); + + /// + /// Defines the property + /// + public static readonly StyledProperty DisplayModeProperty = + AvaloniaProperty.Register(nameof(DisplayMode), defaultValue: SplitViewDisplayMode.Overlay); + + /// + /// Defines the property + /// + public static readonly DirectProperty IsPaneOpenProperty = + AvaloniaProperty.RegisterDirect(nameof(IsPaneOpen), + x => x.IsPaneOpen, (x, v) => x.IsPaneOpen = v); + + /// + /// Defines the property + /// + public static readonly StyledProperty OpenPaneLengthProperty = + AvaloniaProperty.Register(nameof(OpenPaneLength), defaultValue: 320); + + /// + /// Defines the property + /// + public static readonly StyledProperty PaneBackgroundProperty = + AvaloniaProperty.Register(nameof(PaneBackground)); + + /// + /// Defines the property + /// + public static readonly StyledProperty PanePlacementProperty = + AvaloniaProperty.Register(nameof(PanePlacement)); + + /// + /// Defines the property + /// + public static readonly StyledProperty PaneProperty = + AvaloniaProperty.Register(nameof(Pane)); + + /// + /// Defines the property + /// + public static readonly StyledProperty UseLightDismissOverlayModeProperty = + AvaloniaProperty.Register(nameof(UseLightDismissOverlayMode)); + + /// + /// Defines the property + /// + public static readonly StyledProperty TemplateSettingsProperty = + AvaloniaProperty.Register(nameof(TemplateSettings)); + + private bool _isPaneOpen; + private Panel _pane; + private CompositeDisposable _pointerDisposables; + + public SplitView() + { + PseudoClasses.Add(":overlay"); + PseudoClasses.Add(":left"); + + TemplateSettings = new SplitViewTemplateSettings(); + } + + static SplitView() + { + UseLightDismissOverlayModeProperty.Changed.AddClassHandler((x, v) => x.OnUseLightDismissChanged(v)); + CompactPaneLengthProperty.Changed.AddClassHandler((x, v) => x.OnCompactPaneLengthChanged(v)); + PanePlacementProperty.Changed.AddClassHandler((x, v) => x.OnPanePlacementChanged(v)); + DisplayModeProperty.Changed.AddClassHandler((x, v) => x.OnDisplayModeChanged(v)); + } + + /// + /// Gets or sets the content of the SplitView + /// + [Content] + public IControl Content + { + get => GetValue(ContentProperty); + set => SetValue(ContentProperty, value); + } + + /// + /// Gets or sets the length of the pane when in + /// or mode + /// + public double CompactPaneLength + { + get => GetValue(CompactPaneLengthProperty); + set => SetValue(CompactPaneLengthProperty, value); + } + + /// + /// Gets or sets the for the SplitView + /// + public SplitViewDisplayMode DisplayMode + { + get => GetValue(DisplayModeProperty); + set => SetValue(DisplayModeProperty, value); + } + + /// + /// Gets or sets whether the pane is open or closed + /// + public bool IsPaneOpen + { + get => _isPaneOpen; + set + { + if (value == _isPaneOpen) + { + return; + } + + if (value) + { + OnPaneOpening(this, null); + SetAndRaise(IsPaneOpenProperty, ref _isPaneOpen, value); + + PseudoClasses.Add(":open"); + PseudoClasses.Remove(":closed"); + OnPaneOpened(this, null); + } + else + { + SplitViewPaneClosingEventArgs args = new SplitViewPaneClosingEventArgs(false); + OnPaneClosing(this, args); + if (!args.Cancel) + { + SetAndRaise(IsPaneOpenProperty, ref _isPaneOpen, value); + + PseudoClasses.Add(":closed"); + PseudoClasses.Remove(":open"); + OnPaneClosed(this, null); + } + } + } + } + + /// + /// Gets or sets the length of the pane when open + /// + public double OpenPaneLength + { + get => GetValue(OpenPaneLengthProperty); + set => SetValue(OpenPaneLengthProperty, value); + } + + /// + /// Gets or sets the background of the pane + /// + public IBrush PaneBackground + { + get => GetValue(PaneBackgroundProperty); + set => SetValue(PaneBackgroundProperty, value); + } + + /// + /// Gets or sets the for the SplitView + /// + public SplitViewPanePlacement PanePlacement + { + get => GetValue(PanePlacementProperty); + set => SetValue(PanePlacementProperty, value); + } + + /// + /// Gets or sets the Pane for the SplitView + /// + public IControl Pane + { + get => GetValue(PaneProperty); + set => SetValue(PaneProperty, value); + } + + /// + /// Gets or sets whether WinUI equivalent LightDismissOverlayMode is enabled + /// When enabled, and the pane is open in Overlay or CompactOverlay mode, + /// the contents of the splitview are darkened to visually separate the open pane + /// and the rest of the SplitView + /// + public bool UseLightDismissOverlayMode + { + get => GetValue(UseLightDismissOverlayModeProperty); + set => SetValue(UseLightDismissOverlayModeProperty, value); + } + + /// + /// Gets or sets the TemplateSettings for the SplitView + /// + public SplitViewTemplateSettings TemplateSettings + { + get => GetValue(TemplateSettingsProperty); + set => SetValue(TemplateSettingsProperty, value); + } + + /// + /// Fired when the pane is closed + /// + public event EventHandler PaneClosed; + + /// + /// Fired when the pane is closing + /// + public event EventHandler PaneClosing; + + /// + /// Fired when the pane is opened + /// + public event EventHandler PaneOpened; + + /// + /// Fired when the pane is opening + /// + public event EventHandler PaneOpening; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _pane = e.NameScope.Find("PART_PaneRoot"); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + var topLevel = this.VisualRoot; + if (topLevel is Window window) + { + //Logic adapted from Popup + //Basically if we're using an overlay DisplayMode, close the pane if we don't click on the pane + IDisposable subscribeToEventHandler(T target, TEventHandler handler, + Action subscribe, Action unsubscribe) + { + subscribe(target, handler); + return Disposable.Create((unsubscribe, target, handler), state => state.unsubscribe(state.target, state.handler)); + } + + _pointerDisposables = new CompositeDisposable( + window.AddDisposableHandler(PointerPressedEvent, PointerPressedOutside, RoutingStrategies.Tunnel), + InputManager.Instance?.Process.Subscribe(OnNonClientClick), + subscribeToEventHandler(window, Window_Deactivated, + (x, handler) => x.Deactivated += handler, (x, handler) => x.Deactivated -= handler), + subscribeToEventHandler(window.PlatformImpl, OnWindowLostFocus, + (x, handler) => x.LostFocus += handler, (x, handler) => x.LostFocus -= handler)); + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _pointerDisposables?.Dispose(); + } + + private void OnWindowLostFocus() + { + if (IsPaneOpen && ShouldClosePane()) + { + IsPaneOpen = false; + } + } + + private void PointerPressedOutside(object sender, PointerPressedEventArgs e) + { + if (!IsPaneOpen) + { + return; + } + + //If we click within the Pane, don't do anything + //Otherwise, ClosePane if open & using an overlay display mode + bool closePane = ShouldClosePane(); + if (!closePane) + { + return; + } + + var src = e.Source as IVisual; + while (src != null) + { + if (src == _pane) + { + closePane = false; + break; + } + + src = src.VisualParent; + } + if (closePane) + { + IsPaneOpen = false; + e.Handled = true; + } + } + + private void OnNonClientClick(RawInputEventArgs obj) + { + if (!IsPaneOpen) + { + return; + } + + var mouse = obj as RawPointerEventArgs; + if (mouse?.Type == RawPointerEventType.NonClientLeftButtonDown) + + { + if (ShouldClosePane()) + IsPaneOpen = false; + } + } + + private void Window_Deactivated(object sender, EventArgs e) + { + if (IsPaneOpen && ShouldClosePane()) + { + IsPaneOpen = false; + } + } + + private bool ShouldClosePane() + { + return (DisplayMode == SplitViewDisplayMode.CompactOverlay || DisplayMode == SplitViewDisplayMode.Overlay); + } + + protected virtual void OnPaneOpening(SplitView sender, EventArgs args) + { + PaneOpening?.Invoke(sender, args); + } + + protected virtual void OnPaneOpened(SplitView sender, EventArgs args) + { + PaneOpened?.Invoke(sender, args); + } + + protected virtual void OnPaneClosing(SplitView sender, SplitViewPaneClosingEventArgs args) + { + PaneClosing?.Invoke(sender, args); + } + + protected virtual void OnPaneClosed(SplitView sender, EventArgs args) + { + PaneClosed?.Invoke(sender, args); + } + + private void OnCompactPaneLengthChanged(AvaloniaPropertyChangedEventArgs e) + { + var newLen = (double)e.NewValue; + var displayMode = DisplayMode; + if (displayMode == SplitViewDisplayMode.CompactInline) + { + TemplateSettings.ClosedPaneWidth = newLen; + } + else if (displayMode == SplitViewDisplayMode.CompactOverlay) + { + TemplateSettings.ClosedPaneWidth = newLen; + TemplateSettings.PaneColumnGridLength = new GridLength(newLen, GridUnitType.Pixel); + } + } + + private void OnPanePlacementChanged(AvaloniaPropertyChangedEventArgs e) + { + var oldState = e.OldValue.ToString().ToLower(); + var newState = e.NewValue.ToString().ToLower(); + PseudoClasses.Remove($":{oldState}"); + PseudoClasses.Add($":{newState}"); + } + + private void OnDisplayModeChanged(AvaloniaPropertyChangedEventArgs e) + { + var oldState = e.OldValue.ToString().ToLower(); + var newState = e.NewValue.ToString().ToLower(); + + PseudoClasses.Remove($":{oldState}"); + PseudoClasses.Add($":{newState}"); + + var (closedPaneWidth, paneColumnGridLength) = (SplitViewDisplayMode)e.NewValue switch + { + SplitViewDisplayMode.Overlay => (0, new GridLength(0, GridUnitType.Pixel)), + SplitViewDisplayMode.CompactOverlay => (CompactPaneLength, new GridLength(CompactPaneLength, GridUnitType.Pixel)), + SplitViewDisplayMode.Inline => (0, new GridLength(0, GridUnitType.Auto)), + SplitViewDisplayMode.CompactInline => (CompactPaneLength, new GridLength(0, GridUnitType.Auto)), + _ => throw new NotImplementedException(), + }; + TemplateSettings.ClosedPaneWidth = closedPaneWidth; + TemplateSettings.PaneColumnGridLength = paneColumnGridLength; + } + + private void OnUseLightDismissChanged(AvaloniaPropertyChangedEventArgs e) + { + var mode = (bool)e.NewValue; + PseudoClasses.Set(":lightdismiss", mode); + } + } +} diff --git a/src/Avalonia.Controls/SplitViewPaneClosingEventArgs.cs b/src/Avalonia.Controls/SplitViewPaneClosingEventArgs.cs new file mode 100644 index 0000000000..46fb2d161b --- /dev/null +++ b/src/Avalonia.Controls/SplitViewPaneClosingEventArgs.cs @@ -0,0 +1,14 @@ +using System; + +namespace Avalonia.Controls +{ + public class SplitViewPaneClosingEventArgs : EventArgs + { + public bool Cancel { get; set; } + + public SplitViewPaneClosingEventArgs(bool cancel) + { + Cancel = cancel; + } + } +} diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml index d68ed19574..5ab50f5b73 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml @@ -889,5 +889,7 @@ 1 32 + + diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml index bbf68d46ef..88040a69e7 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml @@ -886,5 +886,7 @@ 1 32 + + diff --git a/src/Avalonia.Themes.Fluent/FluentTheme.xaml b/src/Avalonia.Themes.Fluent/FluentTheme.xaml index cef9625e1d..0cbbb77d38 100644 --- a/src/Avalonia.Themes.Fluent/FluentTheme.xaml +++ b/src/Avalonia.Themes.Fluent/FluentTheme.xaml @@ -53,6 +53,7 @@ + diff --git a/src/Avalonia.Themes.Fluent/SplitView.xaml b/src/Avalonia.Themes.Fluent/SplitView.xaml new file mode 100644 index 0000000000..71e92459f1 --- /dev/null +++ b/src/Avalonia.Themes.Fluent/SplitView.xaml @@ -0,0 +1,219 @@ + + + + 320 + 48 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs index df7c0693e1..fa2ed61e65 100644 --- a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Animation.Easings; using Avalonia.Controls.Shapes; using Avalonia.Media; using Avalonia.Styling; @@ -25,6 +26,16 @@ namespace Avalonia.Animation.UnitTests Assert.Equal(4, keySpline.ControlPointY2); } + [Theory] + [InlineData("1,2F,3,4")] + [InlineData("Foo,Bar,Fee,Buzz")] + public void Can_Handle_Invalid_String_KeySpline_Via_TypeConverter(string input) + { + var conv = new KeySplineTypeConverter(); + + Assert.ThrowsAny(() => (KeySpline)conv.ConvertFrom(input)); + } + [Theory] [InlineData(0.00)] [InlineData(0.50)] @@ -46,6 +57,22 @@ namespace Avalonia.Animation.UnitTests Assert.Throws(() => keySpline.ControlPointX2 = input); } + [Fact] + public void SplineEasing_Can_Be_Mutated() + { + var easing = new SplineEasing(); + + Assert.Equal(0, easing.Ease(0)); + Assert.Equal(1, easing.Ease(1)); + + easing.X1 = 0.25; + easing.Y1 = 0.5; + easing.X2 = 0.75; + easing.Y2 = 1.0; + + Assert.NotEqual(0.5, easing.Ease(0.5)); + } + /* To get the test values for the KeySpline test, you can: 1) Grab the WPF sample for KeySpline animations from https://github.com/microsoft/WPF-Samples/tree/master/Animation/KeySplineAnimations @@ -141,5 +168,73 @@ namespace Avalonia.Animation.UnitTests expected = 1.8016358493761722; Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); } + + [Fact] + public void Check_KeySpline_Parsing_Is_Correct() + { + var keyframe1 = new KeyFrame() + { + Setters = + { + new Setter(RotateTransform.AngleProperty, -2.5d), + }, + KeyTime = TimeSpan.FromSeconds(0) + }; + + var keyframe2 = new KeyFrame() + { + Setters = + { + new Setter(RotateTransform.AngleProperty, 2.5d), + }, + KeyTime = TimeSpan.FromSeconds(5), + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(5), + Children = + { + keyframe1, + keyframe2 + }, + IterationCount = new IterationCount(5), + PlaybackDirection = PlaybackDirection.Alternate, + Easing = Easing.Parse("0.1123555056179775,0.657303370786517,0.8370786516853934,0.499999999999999999") + }; + + var rotateTransform = new RotateTransform(-2.5); + var rect = new Rectangle() + { + RenderTransform = rotateTransform + }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(rect, clock); + + // position is what you'd expect at end and beginning + clock.Step(TimeSpan.Zero); + Assert.Equal(rotateTransform.Angle, -2.5); + clock.Step(TimeSpan.FromSeconds(5)); + Assert.Equal(rotateTransform.Angle, 2.5); + + // test some points in between end and beginning + var tolerance = 0.01; + clock.Step(TimeSpan.Parse("00:00:10.0153932")); + var expected = -2.4122350198982545; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:11.2655407")); + expected = -0.37153223002125113; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:12.6158773")); + expected = 0.3967885416786294; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:14.6495256")); + expected = 1.8016358493761722; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs b/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs new file mode 100644 index 0000000000..03653ec42c --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + + public class SplitViewTests + { + [Fact] + public void SplitView_PaneOpening_Should_Fire_Before_PaneOpened() + { + var splitView = new SplitView(); + + bool handledOpening = false; + splitView.PaneOpening += (x, e) => + { + handledOpening = true; + }; + + splitView.PaneOpened += (x, e) => + { + Assert.True(handledOpening); + }; + + splitView.IsPaneOpen = true; + } + + [Fact] + public void SplitView_PaneClosing_Should_Fire_Before_PaneClosed() + { + var splitView = new SplitView(); + splitView.IsPaneOpen = true; + + bool handledClosing = false; + splitView.PaneClosing += (x, e) => + { + handledClosing = true; + }; + + splitView.PaneClosed += (x, e) => + { + Assert.True(handledClosing); + }; + + splitView.IsPaneOpen = false; + } + + [Fact] + public void SplitView_Cancel_Close_Should_Prevent_Pane_From_Closing() + { + var splitView = new SplitView(); + splitView.IsPaneOpen = true; + + splitView.PaneClosing += (x, e) => + { + e.Cancel = true; + }; + + splitView.IsPaneOpen = false; + + Assert.True(splitView.IsPaneOpen); + } + } +}