A cross-platform UI framework for .NET
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

620 lines
23 KiB

using System;
using System.Collections.Generic;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using Avalonia.Threading;
using SkiaSharp;
namespace ControlCatalog.Pages
{
/// <summary>
/// A fluid navigation bar that replicates the Flutter fluid_nav_bar vignette.
/// The bar background has a bezier "dip" that travels to the selected tab.
/// Each icon is drawn progressively using SKPathMeasure for the fill animation.
/// </summary>
public class FluidNavBar : Control, Avalonia.Rendering.ICustomHitTest
{
internal const double NominalHeight = 56.0;
internal const double CircleRadius = 25.0;
internal const double ActiveFloat = 16.0; // px the circle rises
internal const double IconDrawScale = 0.9; // icon scale within circle
internal const double ScaleCurveScale = 0.50;
internal const double FloatLinearPIn = 0.28;
internal const double FillLinearPIn = 0.25;
internal const double XAnimDuration = 0.620; // s — X bump travel
internal const double YDipDuration = 0.300; // s — dip down
internal const double YBounceDelay = 0.500; // s — wait before bounce
internal const double YBounceDuration = 1.200; // s — elastic bounce up
internal const double FloatUpDuration = 1.666; // s — circle rising
internal const double FloatDownDuration = 0.833; // s — circle falling
public static readonly StyledProperty<IList<FluidNavItem>> ItemsProperty =
AvaloniaProperty.Register<FluidNavBar, IList<FluidNavItem>>(
nameof(Items), new List<FluidNavItem>());
public static readonly StyledProperty<int> SelectedIndexProperty =
AvaloniaProperty.Register<FluidNavBar, int>(nameof(SelectedIndex), 0);
public static readonly StyledProperty<Color> BarColorProperty =
AvaloniaProperty.Register<FluidNavBar, Color>(nameof(BarColor), Colors.White);
public static readonly StyledProperty<Color> ButtonColorProperty =
AvaloniaProperty.Register<FluidNavBar, Color>(nameof(ButtonColor), Colors.White);
public static readonly StyledProperty<Color> ActiveIconColorProperty =
AvaloniaProperty.Register<FluidNavBar, Color>(nameof(ActiveIconColor), Colors.Black);
public static readonly StyledProperty<Color> InactiveIconColorProperty =
AvaloniaProperty.Register<FluidNavBar, Color>(
nameof(InactiveIconColor), Color.FromArgb(140, 120, 120, 120));
private double _xCurrent = -1; // -1 = not yet initialised
private double _lastWidth = -1; // tracks width changes for resize correction
private double _xStart, _xTarget, _xAnimStartSec;
private double _yValue = 1.0; // 0 = deepest dip, 1 = flat
private double _yDipStartSec;
private bool _yBounceStarted;
private double _yBounceStartSec;
// per-item (length = Items.Count after OnItemsChanged)
private double[] _floatProgress = Array.Empty<double>();
private double[] _floatStartSec = Array.Empty<double>();
private bool[] _floatGoingUp = Array.Empty<bool>();
// Parsed Skia paths — owned here, disposed on detach / items change
private SKPath?[] _parsedPaths = Array.Empty<SKPath?>();
private DispatcherTimer? _animTimer;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private bool _animating;
public IList<FluidNavItem> Items
{
get => GetValue(ItemsProperty);
set => SetValue(ItemsProperty, value);
}
public int SelectedIndex
{
get => GetValue(SelectedIndexProperty);
set => SetValue(SelectedIndexProperty, value);
}
public Color BarColor
{
get => GetValue(BarColorProperty);
set => SetValue(BarColorProperty, value);
}
public Color ButtonColor
{
get => GetValue(ButtonColorProperty);
set => SetValue(ButtonColorProperty, value);
}
public Color ActiveIconColor
{
get => GetValue(ActiveIconColorProperty);
set => SetValue(ActiveIconColorProperty, value);
}
public Color InactiveIconColor
{
get => GetValue(InactiveIconColorProperty);
set => SetValue(InactiveIconColorProperty, value);
}
public event EventHandler<int>? SelectionChanged;
public FluidNavBar()
{
ClipToBounds = false;
Height = NominalHeight;
Cursor = new Cursor(StandardCursorType.Hand);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ItemsProperty)
OnItemsChanged();
else if (change.Property == SelectedIndexProperty)
OnSelectedIndexChanged(change.GetOldValue<int>(), change.GetNewValue<int>());
else if (change.Property == BarColorProperty
|| change.Property == ButtonColorProperty
|| change.Property == ActiveIconColorProperty
|| change.Property == InactiveIconColorProperty)
InvalidateVisual();
}
public bool HitTest(Point point)
{
return point.X >= 0 && point.X <= Bounds.Width
&& point.Y >= -(ActiveFloat + CircleRadius)
&& point.Y <= Bounds.Height;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var n = Items?.Count ?? 0;
if (n == 0 || Bounds.Width <= 0) return;
var pos = e.GetPosition(this);
var index = (int)(pos.X / (Bounds.Width / n));
index = Math.Clamp(index, 0, n - 1);
if (index != SelectedIndex)
{
SetCurrentValue(SelectedIndexProperty, index);
SelectionChanged?.Invoke(this, index);
}
e.Handled = true;
}
protected override Size MeasureOverride(Size availableSize)
{
var w = double.IsPositiveInfinity(availableSize.Width) ? 300 : availableSize.Width;
return new Size(w, NominalHeight);
}
protected override Size ArrangeOverride(Size finalSize)
{
var w = finalSize.Width;
if (w > 0)
{
if (_xCurrent < 0 || _lastWidth < 0)
{
// First layout — snap everything to the current selection
_xCurrent = IndexToX(SelectedIndex, w);
_xTarget = _xCurrent;
_xStart = _xCurrent;
}
else if (Math.Abs(w - _lastWidth) > 0.5)
{
// Width changed (resize) — scale pixel positions proportionally
// so the bump stays over the correct slot
var ratio = w / _lastWidth;
_xCurrent = _xCurrent * ratio;
_xStart = _xStart * ratio;
_xTarget = IndexToX(SelectedIndex, w);
InvalidateVisual();
}
_lastWidth = w;
}
return new Size(w > 0 ? w : 300, NominalHeight);
}
public override void Render(DrawingContext context)
{
var w = Bounds.Width;
var h = Bounds.Height;
if (w <= 0 || h <= 0 || Items == null || Items.Count == 0) return;
var n = Items.Count;
// Initialise _xCurrent if layout didn't run yet
if (_xCurrent < 0)
{
_xCurrent = IndexToX(SelectedIndex, w);
_xTarget = _xCurrent;
}
// Snapshot per-item animation state for this frame
var slotCenters = new double[n];
var floatOffsets = new double[n];
var scaleYValues = new double[n];
var fillAmounts = new double[n];
for (int i = 0; i < n; i++)
slotCenters[i] = IndexToX(i, w);
for (int i = 0; i < n; i++)
{
var p = i < _floatProgress.Length ? _floatProgress[i] : (i == SelectedIndex ? 1.0 : 0.0);
var goUp = i < _floatGoingUp.Length ? _floatGoingUp[i] : (i == SelectedIndex);
// Float offset — uses LinearPoint(0.28, 0) to delay start, then elastic/quintic easing
var linearP = LinearPoint(p, FloatLinearPIn, 0.0);
var floatEased = goUp ? ElasticOut(linearP, 0.38) : EaseInQuint(linearP);
floatOffsets[i] = ActiveFloat * floatEased;
// Scale Y squish via CenteredElastic curves
var centered = goUp ? CenteredElasticOut(p, 0.6) : CenteredElasticIn(p, 0.6);
scaleYValues[i] = 0.75 + centered * ScaleCurveScale;
// Icon fill — LinearPoint(0.25, 1.0) adds a slight draw delay vs float
fillAmounts[i] = LinearPoint(p, FillLinearPIn, 1.0);
}
// Clamp scaleY to sane range to avoid SVG-transform oddities
for (int i = 0; i < n; i++)
scaleYValues[i] = Math.Max(0.1, Math.Min(1.5, scaleYValues[i]));
var op = new FluidNavBarRenderOp(
new Rect(0, -(ActiveFloat + CircleRadius), w, h + ActiveFloat + CircleRadius),
(float)w, (float)h,
(float)_xCurrent, (float)_yValue,
slotCenters, floatOffsets, scaleYValues, fillAmounts,
_parsedPaths,
BarColor, ButtonColor, ActiveIconColor, InactiveIconColor);
context.Custom(op);
}
private void OnItemsChanged()
{
foreach (var p in _parsedPaths) p?.Dispose();
var n = Items?.Count ?? 0;
_parsedPaths = new SKPath?[n];
_floatProgress = new double[n];
_floatStartSec = new double[n];
_floatGoingUp = new bool[n];
for (int i = 0; i < n; i++)
{
var svg = Items![i].SvgPath;
if (!string.IsNullOrEmpty(svg))
_parsedPaths[i] = SKPath.ParseSvgPathData(svg);
}
var sel = Math.Clamp(SelectedIndex, 0, Math.Max(0, n - 1));
for (int i = 0; i < n; i++)
{
_floatProgress[i] = i == sel ? 1.0 : 0.0;
_floatGoingUp[i] = i == sel;
}
_xCurrent = -1; // force re-init on next arrange/render
InvalidateVisual();
}
private void OnSelectedIndexChanged(int oldIndex, int newIndex)
{
var n = _floatProgress.Length;
if (n == 0) return;
newIndex = Math.Clamp(newIndex, 0, n - 1);
oldIndex = Math.Clamp(oldIndex, 0, n - 1);
if (oldIndex == newIndex) return;
var now = _clock.Elapsed.TotalSeconds;
// X: slide bump from old to new position
if (_xCurrent < 0 && Bounds.Width > 0)
_xCurrent = IndexToX(oldIndex, Bounds.Width);
_xStart = _xCurrent;
_xTarget = Bounds.Width > 0 ? IndexToX(newIndex, Bounds.Width) : _xStart;
_xAnimStartSec = now;
// Y: dip then elastic bounce
_yValue = 1.0;
_yDipStartSec = now;
_yBounceStarted = false;
// Per-button float
_floatGoingUp[oldIndex] = false;
_floatStartSec[oldIndex] = now;
_floatGoingUp[newIndex] = true;
_floatStartSec[newIndex] = now;
StartAnimation();
}
private void StartAnimation()
{
if (_animating) return;
_animating = true;
_animTimer = new DispatcherTimer(
TimeSpan.FromSeconds(1.0 / 60.0),
DispatcherPriority.Render,
OnAnimTick);
_animTimer.Start();
}
private void StopAnimation()
{
_animTimer?.Stop();
_animTimer = null;
_animating = false;
}
private void OnAnimTick(object? sender, EventArgs e)
{
var now = _clock.Elapsed.TotalSeconds;
var anyActive = false;
var xElapsed = now - _xAnimStartSec;
if (xElapsed < XAnimDuration)
{
_xCurrent = _xStart + (_xTarget - _xStart) * (xElapsed / XAnimDuration);
anyActive = true;
}
else
{
_xCurrent = _xTarget;
}
var yDipElapsed = now - _yDipStartSec;
if (yDipElapsed < YDipDuration)
{
_yValue = 1.0 - yDipElapsed / YDipDuration;
anyActive = true;
}
else
{
_yValue = 0.0;
if (!_yBounceStarted && yDipElapsed >= YBounceDelay)
{
_yBounceStarted = true;
_yBounceStartSec = now;
}
if (_yBounceStarted)
{
var bt = now - _yBounceStartSec;
if (bt < YBounceDuration)
{
_yValue = ElasticOut(bt / YBounceDuration, 0.38);
anyActive = true;
}
else
{
_yValue = 1.0;
}
}
}
for (int i = 0; i < _floatProgress.Length; i++)
{
var elapsed = now - _floatStartSec[i];
var duration = _floatGoingUp[i] ? FloatUpDuration : FloatDownDuration;
if (elapsed < duration)
{
var t = elapsed / duration;
_floatProgress[i] = _floatGoingUp[i] ? t : 1.0 - t;
anyActive = true;
}
else
{
_floatProgress[i] = _floatGoingUp[i] ? 1.0 : 0.0;
}
}
InvalidateVisual();
if (!anyActive)
StopAnimation();
}
private double IndexToX(int index, double width)
{
var n = Items?.Count ?? 1;
if (n <= 0) n = 1;
return (index + 0.5) * (width / n);
}
internal static double ElasticOut(double t, double period = 0.4)
{
if (t <= 0) return 0;
if (t >= 1) return 1;
var s = period / 4.0;
return Math.Pow(2.0, -10.0 * t) * Math.Sin((t - s) * 2.0 * Math.PI / period) + 1.0;
}
private static double CenteredElasticOut(double t, double period = 0.4)
{
return Math.Pow(2.0, -10.0 * t) * Math.Sin(t * 2.0 * Math.PI / period) + 0.5;
}
private static double CenteredElasticIn(double t, double period = 0.4)
{
return -Math.Pow(2.0, 10.0 * (t - 1.0)) * Math.Sin((t - 1.0) * 2.0 * Math.PI / period) + 0.5;
}
internal static double LinearPoint(double x, double pIn, double pOut)
{
if (pIn <= 0) return pOut;
var lowerScale = pOut / pIn;
var upperScale = (1.0 - pOut) / (1.0 - pIn);
var upperOff = 1.0 - upperScale;
return x < pIn ? x * lowerScale : x * upperScale + upperOff;
}
private static double EaseInQuint(double t) => t * t * t * t * t;
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
StopAnimation();
}
private sealed class FluidNavBarRenderOp : ICustomDrawOperation
{
private readonly float _w, _h, _xCenter, _normY;
private readonly double[] _slots, _floatOff, _scaleY, _fill;
private readonly SKPath?[] _paths;
private readonly Color _bar, _btn, _active, _inactive;
public Rect Bounds { get; }
public FluidNavBarRenderOp(
Rect bounds,
float w, float h,
float xCenter, float normY,
double[] slots, double[] floatOff, double[] scaleY, double[] fill,
SKPath?[] paths,
Color bar, Color btn, Color active, Color inactive)
{
Bounds = bounds;
_w = w; _h = h;
_xCenter = xCenter; _normY = normY;
_slots = slots; _floatOff = floatOff;
_scaleY = scaleY; _fill = fill;
_paths = paths;
_bar = bar; _btn = btn;
_active = active; _inactive = inactive;
}
public bool HitTest(Point p) => false;
public bool Equals(ICustomDrawOperation? other) => false;
public void Dispose() { }
public void Render(ImmediateDrawingContext context)
{
var lease = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (lease == null) return;
using var l = lease.Lease();
var canvas = l.SkCanvas;
int save = canvas.Save();
try
{
DrawBackground(canvas);
for (int i = 0; i < _slots.Length; i++)
DrawButton(canvas, i);
}
finally
{
canvas.RestoreToCount(save);
}
}
private void DrawBackground(SKCanvas canvas)
{
const float rTop = 54f, rBot = 44f;
const float hcTop = 0.6f, hcBot = 0.5f;
const float pcTop = 0.35f, pcBot = 0.85f;
const float tY = -10f, bY = 54f;
const float tD = 0f, bD = 6f;
float norm = (float)(LinearPoint(_normY, 0.5, 2.0) / 2.0);
float r = Lerp(rTop, rBot, norm);
float anchr = Lerp(r * hcTop, r * hcBot, (float)LinearPoint(norm, 0.5, 0.75));
float dipc = Lerp(r * pcTop, r * pcBot, (float)LinearPoint(norm, 0.5, 0.80));
float y = Lerp(tY, bY, (float)LinearPoint(norm, 0.2, 0.70));
float dist = Lerp(tD, bD, (float)LinearPoint(norm, 0.5, 0.00));
float x0 = _xCenter - dist / 2f;
float x1 = _xCenter + dist / 2f;
using var path = new SKPath();
path.MoveTo(0, 0);
path.LineTo(x0 - r, 0);
path.CubicTo(x0 - r + anchr, 0, x0 - dipc, y, x0, y);
path.LineTo(x1, y);
path.CubicTo(x1 + dipc, y, x1 + r - anchr, 0, x1 + r, 0);
path.LineTo(_w, 0);
path.LineTo(_w, _h);
path.LineTo(0, _h);
path.Close();
using var paint = new SKPaint { Color = ToSK(_bar), IsAntialias = true };
canvas.DrawPath(path, paint);
}
private void DrawButton(SKCanvas canvas, int i)
{
var cx = (float)_slots[i];
var cy = _h / 2f;
var fo = (float)_floatOff[i];
var sy = (float)_scaleY[i];
var fa = (float)_fill[i];
const float r = (float)CircleRadius;
// Circle — just translated up, not scaled
using var cp = new SKPaint { Color = ToSK(_btn), IsAntialias = true };
canvas.DrawCircle(cx, cy - fo, r, cp);
// Icon
if (i < _paths.Length && _paths[i] != null)
DrawIcon(canvas, _paths[i]!, cx, cy - fo, sy, fa);
}
private void DrawIcon(SKCanvas canvas, SKPath path, float cx, float cy,
float scaleY, float fillAmount)
{
const float s = (float)IconDrawScale;
int save = canvas.Save();
canvas.Translate(cx, cy);
canvas.Scale(s, s * scaleY);
// Grey background stroke (full path, unselected look)
using var bg = new SKPaint
{
Style = SKPaintStyle.Stroke,
StrokeWidth = 2.4f,
StrokeCap = SKStrokeCap.Round,
StrokeJoin = SKStrokeJoin.Round,
Color = ToSK(_inactive),
IsAntialias = true
};
canvas.DrawPath(path, bg);
// Foreground stroke, trimmed progressively with SKPathMeasure
if (fillAmount > 0f)
{
using var fg = new SKPaint
{
Style = SKPaintStyle.Stroke,
StrokeWidth = 2.4f,
StrokeCap = SKStrokeCap.Round,
StrokeJoin = SKStrokeJoin.Round,
Color = ToSK(_active),
IsAntialias = true
};
DrawTrimmedPath(canvas, path, fillAmount, fg);
}
canvas.RestoreToCount(save);
}
// Iterates all contours and draws each trimmed to fillAmount of its length.
// Direct port of Flutter's extractPartialPath behavior.
private static void DrawTrimmedPath(SKCanvas canvas, SKPath path,
float fillAmount, SKPaint paint)
{
using var measure = new SKPathMeasure(path, false);
do
{
var len = measure.Length;
if (len <= 0f) continue;
using var seg = new SKPath();
if (measure.GetSegment(0f, len * fillAmount, seg, true))
canvas.DrawPath(seg, paint);
}
while (measure.NextContour());
}
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
private static double LinearPoint(double x, double pIn, double pOut)
{
if (pIn <= 0) return pOut;
var lo = pOut / pIn;
var hi = (1.0 - pOut) / (1.0 - pIn);
return x < pIn ? x * lo : x * hi + (1.0 - hi);
}
private static SKColor ToSK(Color c) => new SKColor(c.R, c.G, c.B, c.A);
}
}
}