// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using SkiaSharp; namespace Microsoft.Maui.Platform; /// /// A page that displays a flyout menu and detail content. /// public class SkiaFlyoutPage : SkiaLayoutView { private SkiaView? _flyout; private SkiaView? _detail; private bool _isPresented = false; private float _flyoutWidth = 300f; private float _flyoutAnimationProgress = 0f; private bool _gestureEnabled = true; // Gesture tracking private bool _isDragging = false; private float _dragStartX; private float _dragCurrentX; /// /// Gets or sets the flyout content (menu). /// public SkiaView? Flyout { get => _flyout; set { if (_flyout != value) { if (_flyout != null) { RemoveChild(_flyout); } _flyout = value; if (_flyout != null) { AddChild(_flyout); } Invalidate(); } } } /// /// Gets or sets the detail content (main content). /// public SkiaView? Detail { get => _detail; set { if (_detail != value) { if (_detail != null) { RemoveChild(_detail); } _detail = value; if (_detail != null) { AddChild(_detail); } Invalidate(); } } } /// /// Gets or sets whether the flyout is currently presented. /// public bool IsPresented { get => _isPresented; set { if (_isPresented != value) { _isPresented = value; _flyoutAnimationProgress = value ? 1f : 0f; IsPresentedChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } } } /// /// Gets or sets the width of the flyout panel. /// public float FlyoutWidth { get => _flyoutWidth; set { if (_flyoutWidth != value) { _flyoutWidth = Math.Max(100, value); InvalidateMeasure(); Invalidate(); } } } /// /// Gets or sets whether swipe gestures are enabled. /// public bool GestureEnabled { get => _gestureEnabled; set => _gestureEnabled = value; } /// /// The flyout layout behavior. /// public FlyoutLayoutBehavior FlyoutLayoutBehavior { get; set; } = FlyoutLayoutBehavior.Default; /// /// Background color of the scrim when flyout is open. /// public SKColor ScrimColor { get; set; } = new SKColor(0, 0, 0, 100); /// /// Shadow width for the flyout. /// public float ShadowWidth { get; set; } = 8f; /// /// Event raised when IsPresented changes. /// public event EventHandler? IsPresentedChanged; protected override SKSize MeasureOverride(SKSize availableSize) { // Measure flyout if (_flyout != null) { _flyout.Measure(new SKSize(FlyoutWidth, availableSize.Height)); } // Measure detail to full size if (_detail != null) { _detail.Measure(availableSize); } return availableSize; } protected override SKRect ArrangeOverride(SKRect bounds) { // Arrange detail to fill the entire area if (_detail != null) { _detail.Arrange(bounds); } // Arrange flyout (positioned based on animation progress) if (_flyout != null) { float flyoutX = bounds.Left - FlyoutWidth + (FlyoutWidth * _flyoutAnimationProgress); var flyoutBounds = new SKRect( flyoutX, bounds.Top, flyoutX + FlyoutWidth, bounds.Bottom); _flyout.Arrange(flyoutBounds); } return bounds; } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { canvas.Save(); canvas.ClipRect(bounds); // Draw detail content first _detail?.Draw(canvas); // If flyout is visible, draw scrim and flyout if (_flyoutAnimationProgress > 0) { // Draw scrim (semi-transparent overlay) using var scrimPaint = new SKPaint { Color = ScrimColor.WithAlpha((byte)(ScrimColor.Alpha * _flyoutAnimationProgress)), Style = SKPaintStyle.Fill }; canvas.DrawRect(Bounds, scrimPaint); // Draw flyout shadow if (_flyout != null && ShadowWidth > 0) { DrawFlyoutShadow(canvas); } // Draw flyout _flyout?.Draw(canvas); } canvas.Restore(); } private void DrawFlyoutShadow(SKCanvas canvas) { if (_flyout == null) return; float shadowRight = _flyout.Bounds.Right; var shadowRect = new SKRect( shadowRight, Bounds.Top, shadowRight + ShadowWidth, Bounds.Bottom); using var shadowPaint = new SKPaint { Shader = SKShader.CreateLinearGradient( new SKPoint(shadowRect.Left, shadowRect.MidY), new SKPoint(shadowRect.Right, shadowRect.MidY), new SKColor[] { new SKColor(0, 0, 0, 60), SKColors.Transparent }, null, SKShaderTileMode.Clamp) }; canvas.DrawRect(shadowRect, shadowPaint); } public override SkiaView? HitTest(float x, float y) { if (!IsVisible || !Bounds.Contains(x, y)) return null; // If flyout is presented, check if hit is in flyout if (_flyoutAnimationProgress > 0 && _flyout != null) { var flyoutHit = _flyout.HitTest(x, y); if (flyoutHit != null) return flyoutHit; // Hit on scrim closes flyout if (_isPresented) { return this; // Return self to handle scrim tap } } // Check detail content if (_detail != null) { var detailHit = _detail.HitTest(x, y); if (detailHit != null) return detailHit; } return this; } public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled) return; // Check if tap is on scrim (outside flyout but flyout is open) if (_isPresented && _flyout != null && !_flyout.Bounds.Contains(e.X, e.Y)) { IsPresented = false; e.Handled = true; return; } // Start drag gesture if (_gestureEnabled) { _isDragging = true; _dragStartX = e.X; _dragCurrentX = e.X; } base.OnPointerPressed(e); } public override void OnPointerMoved(PointerEventArgs e) { if (_isDragging && _gestureEnabled) { _dragCurrentX = e.X; float delta = _dragCurrentX - _dragStartX; // Calculate new animation progress if (_isPresented) { // Dragging to close _flyoutAnimationProgress = Math.Clamp(1f + (delta / FlyoutWidth), 0f, 1f); } else { // Dragging to open (only from left edge) if (_dragStartX < 30) { _flyoutAnimationProgress = Math.Clamp(delta / FlyoutWidth, 0f, 1f); } } Invalidate(); e.Handled = true; } base.OnPointerMoved(e); } public override void OnPointerReleased(PointerEventArgs e) { if (_isDragging) { _isDragging = false; // Determine final state based on progress if (_flyoutAnimationProgress > 0.5f) { _isPresented = true; _flyoutAnimationProgress = 1f; } else { _isPresented = false; _flyoutAnimationProgress = 0f; } IsPresentedChanged?.Invoke(this, EventArgs.Empty); Invalidate(); } base.OnPointerReleased(e); } /// /// Toggles the flyout presentation state. /// public void ToggleFlyout() { IsPresented = !IsPresented; } } /// /// Defines how the flyout behaves. /// public enum FlyoutLayoutBehavior { /// /// Default behavior based on device/window size. /// Default, /// /// Flyout slides over the detail content. /// Popover, /// /// Flyout and detail are shown side by side. /// Split, /// /// Flyout pushes the detail content. /// SplitOnLandscape, /// /// Flyout is always shown in portrait, side by side in landscape. /// SplitOnPortrait }