diff --git a/src/Avalonia.Controls/SplitView.cs b/src/Avalonia.Controls/SplitView.cs new file mode 100644 index 0000000000..df3db324a3 --- /dev/null +++ b/src/Avalonia.Controls/SplitView.cs @@ -0,0 +1,476 @@ +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); + if (_pointerDisposables != null) + _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 state = (SplitViewDisplayMode)e.NewValue; + //PaneColumn Specs: Width/ColumnWidth + // Overlay - 0px/0px + // CompactOverlay - CompactPaneLength/CompactPaneLength + // Inline - 0px/Auto + // CompactInline - CompactPaneLength/Auto + TemplateSettings.ClosedPaneWidth = (state == SplitViewDisplayMode.CompactInline || + state == SplitViewDisplayMode.CompactOverlay) ? CompactPaneLength : 0; + + TemplateSettings.PaneColumnGridLength = (state == SplitViewDisplayMode.CompactInline || + state == SplitViewDisplayMode.Inline) ? new GridLength(0, GridUnitType.Auto) : + state == SplitViewDisplayMode.Overlay ? new GridLength(0, GridUnitType.Pixel) : + new GridLength(CompactPaneLength, GridUnitType.Pixel); + } + + 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; + } + } +}