diff --git a/src/Uno.UI/FeatureConfiguration.cs b/src/Uno.UI/FeatureConfiguration.cs
index 77a6c8fcc8fa..248f45f6e130 100644
--- a/src/Uno.UI/FeatureConfiguration.cs
+++ b/src/Uno.UI/FeatureConfiguration.cs
@@ -877,5 +877,33 @@ internal static class DebugOptions
public static bool WaitIndefinitelyInEventTester { get; set; }
}
+
+ public static class Shape
+ {
+ ///
+ /// [WebAssembly Only] Gets or sets whether native svg attributes assignments can be postponed until the first arrange pass.
+ ///
+ /// This avoid double assignments(with js interop call) from both OnPropertyChanged and UpdateRender.
+ public static bool WasmDelayUpdateUntilFirstArrange { get; set; } = true;
+
+ ///
+ /// [WebAssembly Only] Gets or sets whether native getBBox() result will be cached.
+ ///
+ public static bool WasmCacheBBoxCalculationResult { get; set; } = true;
+
+ internal const int WasmDefaultBBoxCacheSize = 64;
+ ///
+ /// [WebAssembly Only] Gets or sets the size of getBBox cache. The default size is 64.
+ ///
+#if __WASM__
+ public static int WasmBBoxCacheSize
+ {
+ get => Microsoft.UI.Xaml.Shapes.Shape.BBoxCacheSize;
+ set => Microsoft.UI.Xaml.Shapes.Shape.BBoxCacheSize = value;
+ }
+#else
+ public static int WasmBBoxCacheSize { get; set; } = WasmDefaultBBoxCacheSize;
+#endif
+ }
}
}
diff --git a/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs
index fbd335c40025..eb62e09c0619 100644
--- a/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs
+++ b/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs
@@ -1,4 +1,5 @@
-using Uno.Extensions;
+using System;
+using Uno.Extensions;
using Windows.Foundation;
using Microsoft.UI.Xaml.Wasm;
@@ -28,5 +29,12 @@ protected override Size ArrangeOverride(Size finalSize)
return finalSize;
}
+
+ private protected override string GetBBoxCacheKeyImpl() =>
+#if DEBUG
+ throw new InvalidOperationException("Elipse doesnt use GetBBox. Should the impl change in the future, add key-gen and invalidation mechanism.");
+#else
+ null;
+#endif
}
}
diff --git a/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs
index 1cae19060b0e..770b6dae9988 100644
--- a/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs
+++ b/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs
@@ -28,5 +28,27 @@ protected override Size ArrangeOverride(Size finalSize)
UpdateRender();
return ArrangeAbsoluteShape(finalSize, this);
}
+
+ internal override void OnPropertyChanged2(DependencyPropertyChangedEventArgs args)
+ {
+ base.OnPropertyChanged2(args);
+
+ // invalidate cache key if dp of interests changed
+ if (_bboxCacheKey != null && (
+ args.Property == X1Property ||
+ args.Property == Y1Property ||
+ args.Property == X2Property ||
+ args.Property == Y2Property
+ ))
+ {
+ _bboxCacheKey = null;
+ }
+ }
+
+ private protected override string GetBBoxCacheKeyImpl() => string.Join(',',
+ "line",
+ X1.ToStringInvariant(), Y1.ToStringInvariant(),
+ X2.ToStringInvariant(), Y2.ToStringInvariant()
+ );
}
}
diff --git a/src/Uno.UI/UI/Xaml/Shapes/Path.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Path.wasm.cs
index 6806bb59c8ca..6ba0fad65936 100644
--- a/src/Uno.UI/UI/Xaml/Shapes/Path.wasm.cs
+++ b/src/Uno.UI/UI/Xaml/Shapes/Path.wasm.cs
@@ -1,7 +1,8 @@
#nullable enable
using Windows.Foundation;
-using Microsoft.UI.Xaml.Wasm;
using Uno.UI.Xaml;
+using Microsoft.UI.Xaml.Wasm;
+using Microsoft.UI.Xaml.Media;
namespace Microsoft.UI.Xaml.Shapes
{
@@ -37,9 +38,16 @@ internal override void OnPropertyChanged2(DependencyPropertyChangedEventArgs arg
{
_mainSvgElement.AddChild(data.GetSvgElement());
}
+
+ _bboxCacheKey = null;
}
}
+ private protected override string? GetBBoxCacheKeyImpl() =>
+ Data is GeometryData g
+ ? ("path," + g.Data)
+ : null;
+
internal override bool HitTest(Point relativePosition)
{
// ContainsPoint acts on SVGGeometryElement, and "g" HTML element is not SVGGeometryElement.
diff --git a/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs
index ae3abc53299a..320b146a982d 100644
--- a/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs
+++ b/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs
@@ -27,5 +27,22 @@ protected override Size ArrangeOverride(Size finalSize)
UpdateRender();
return ArrangeAbsoluteShape(finalSize, this);
}
+
+ internal override void OnPropertyChanged2(DependencyPropertyChangedEventArgs args)
+ {
+ base.OnPropertyChanged2(args);
+
+ if (_bboxCacheKey != null && (
+ args.Property == PointsProperty
+ ))
+ {
+ _bboxCacheKey = null;
+ }
+ }
+
+ private protected override string GetBBoxCacheKeyImpl() =>
+ Points is { } points
+ ? ("polygone:" + points.ToCssString())
+ : null;
}
}
diff --git a/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs
index 6d3d856601d2..ea61ed2f2b47 100644
--- a/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs
+++ b/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs
@@ -31,5 +31,22 @@ protected override Size ArrangeOverride(Size finalSize)
UpdateRender();
return ArrangeAbsoluteShape(finalSize, this);
}
+
+ internal override void OnPropertyChanged2(DependencyPropertyChangedEventArgs args)
+ {
+ base.OnPropertyChanged2(args);
+
+ if (_bboxCacheKey != null && (
+ args.Property == PointsProperty
+ ))
+ {
+ _bboxCacheKey = null;
+ }
+ }
+
+ private protected override string GetBBoxCacheKeyImpl() =>
+ Points is { } points
+ ? ("polygone:" + points.ToCssString())
+ : null;
}
}
diff --git a/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs
index c1052c179f68..aa9c80302013 100644
--- a/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs
+++ b/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs
@@ -1,4 +1,5 @@
using Uno.Extensions;
+using System;
using Uno.UI;
using Windows.Foundation;
using Microsoft.UI.Xaml.Media;
@@ -27,5 +28,12 @@ protected override Size ArrangeOverride(Size finalSize)
return finalSize;
}
+
+ private protected override string GetBBoxCacheKeyImpl() =>
+#if DEBUG
+ throw new InvalidOperationException("Rectangle doesnt use GetBBox. Should the impl change in the future, add key-gen and invalidation mechanism");
+#else
+ null;
+#endif
}
}
diff --git a/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs
index dad06272de6d..104723dd3dd8 100644
--- a/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs
+++ b/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs
@@ -1,27 +1,44 @@
using System;
+using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
-using Uno;
-using Uno.Disposables;
-using Uno.Extensions;
+using System.Numerics;
using Windows.Foundation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Wasm;
+using Uno;
+using Uno.Collections;
+using Uno.Disposables;
+using Uno.Extensions;
+using Uno.UI;
+using Uno.UI.Xaml;
using RadialGradientBrush = Microsoft/* UWP don't rename */.UI.Xaml.Media.RadialGradientBrush;
-using System.Numerics;
-using System.Diagnostics;
-using Uno.UI.Xaml;
namespace Microsoft.UI.Xaml.Shapes
{
partial class Shape
{
+ private static readonly LruCache _bboxCache = new(FeatureConfiguration.Shape.WasmDefaultBBoxCacheSize);
+
+ internal static int BBoxCacheSize
+ {
+ get => _bboxCache.Capacity;
+ set => _bboxCache.Capacity = value;
+ }
+ }
+
+ partial class Shape
+ {
+ private protected string _bboxCacheKey;
+
private readonly SerialDisposable _fillBrushSubscription = new SerialDisposable();
private readonly SerialDisposable _strokeBrushSubscription = new SerialDisposable();
private DefsSvgElement _defs;
private protected readonly SvgElement _mainSvgElement;
+ private protected bool _shouldUpdateNative = !FeatureConfiguration.Shape.WasmDelayUpdateUntilFirstArrange;
protected Shape() : base("svg", isSvg: true)
{
@@ -37,14 +54,30 @@ private protected Shape(string mainSvgElementTag) : base("svg", isSvg: true)
private protected void UpdateRender()
{
- OnFillBrushChanged();
- OnStrokeBrushChanged();
- UpdateStrokeThickness();
- UpdateStrokeDashArray();
+ // We can delay the setting of these property until first arrange pass,
+ // so to prevent double-updates from both OnPropertyChanged & ArrangeOverride.
+ // These updates are a little costly as we need to cross the cs-js bridge.
+ _shouldUpdateNative = true;
+
+ // Regarding to caching of GetPathBoundingBox(js: getBBox) result:
+ // On shapes that depends on it, so: Line, Path, Polygon, Polyline
+ // all the properties below (at the time of written) has no effect on getBBox:
+ // > Note that the values of the opacity, visibility, fill, fill-opacity, fill-rule, stroke-dasharray and stroke-dashoffset properties on an element have no effect on the bounding box of an element.
+ // > -- https://svgwg.org/svg2-draft/coords.html#BoundingBoxes
+ // while not mentioned, stroke-width doesnt affect getBBox neither (for the 4 classes of shape mentioned above).
+
+ // StrokeThickness can alter getBBox on Ellipse and Rectangle, but we dont use getBBox in these two.
+
+ OnFillBrushChanged(); // fill
+ OnStrokeBrushChanged(); // stroke
+ UpdateStrokeThickness(); // stroke-width
+ UpdateStrokeDashArray(); // stroke-dasharray
}
private void OnFillBrushChanged()
{
+ if (!_shouldUpdateNative) return;
+
// We don't request an update of the HitTest (UpdateHitTest()) since this element is never expected to be hit testable.
// Note: We also enforce that the default hit test == false is not altered in the OnHitTestVisibilityChanged.
@@ -111,6 +144,8 @@ private void OnFillBrushChanged()
private void OnStrokeBrushChanged()
{
+ if (!_shouldUpdateNative) return;
+
var svgElement = _mainSvgElement;
var stroke = Stroke;
@@ -159,6 +194,8 @@ private void OnStrokeBrushChanged()
private void UpdateStrokeThickness()
{
+ if (!_shouldUpdateNative) return;
+
var svgElement = _mainSvgElement;
var strokeThickness = ActualStrokeThickness;
@@ -174,6 +211,8 @@ private void UpdateStrokeThickness()
private void UpdateStrokeDashArray()
{
+ if (!_shouldUpdateNative) return;
+
var svgElement = _mainSvgElement;
if (StrokeDashArray is not { } strokeDashArray)
@@ -203,7 +242,22 @@ private UIElementCollection GetDefs()
private static Rect GetPathBoundingBox(Shape shape)
{
- return shape._mainSvgElement.GetBBox();
+ if (FeatureConfiguration.Shape.WasmCacheBBoxCalculationResult)
+ {
+ var key = shape.GetBBoxCacheKey();
+ if (!string.IsNullOrEmpty(key))
+ {
+ if (!_bboxCache.TryGetValue(key, out var rect))
+ {
+ _bboxCache[key] = rect = shape._mainSvgElement.GetBBox();
+ }
+
+ return rect;
+ }
+ }
+
+ var result = shape._mainSvgElement.GetBBox();
+ return result;
}
private protected void Render(Shape shape, Size? size = null, double scaleX = 1d, double scaleY = 1d, double renderOriginX = 0d, double renderOriginY = 0d)
@@ -225,5 +279,11 @@ internal override bool HitTest(Point relativePosition)
return (considerFill || considerStroke) &&
WindowManagerInterop.ContainsPoint(_mainSvgElement.HtmlId, relativePosition.X, relativePosition.Y, considerFill, considerStroke);
}
+
+ // lazy impl, and _cacheKey can be invalidated by setting to null
+ private string GetBBoxCacheKey() => _bboxCacheKey ?? (_bboxCacheKey = GetBBoxCacheKeyImpl());
+
+ // note: perf is of concern here. avoid $"string interpolation" and current-culture .ToString, and use string.concat and ToStringInvariant
+ private protected abstract string GetBBoxCacheKeyImpl();
}
}
diff --git a/src/Uno.UWP/Collections/LruCache.cs b/src/Uno.UWP/Collections/LruCache.cs
new file mode 100644
index 000000000000..98669bb5b344
--- /dev/null
+++ b/src/Uno.UWP/Collections/LruCache.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Cache;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Uno.Collections;
+
+public partial class LruCache
+{
+ // essentially, a KeyValuePair where the value is mutable
+ private class KeyedItem
+ {
+ public TKey Key { get; init; }
+ public TValue Value { get; set; }
+
+ public KeyedItem(TKey key, TValue value)
+ {
+ this.Key = key;
+ this.Value = value;
+ }
+ }
+}
+
+///
+/// A least-recently-used cache, keeping only the last n added/updated items.
+///
+///
+///
+public partial class LruCache where TKey : notnull
+{
+ private readonly Dictionary> _map = new();
+ private readonly LinkedList _list = new();
+ private int _capacity;
+
+ public int Count => _map.Count;
+ public int Capacity
+ {
+ get => _capacity;
+ set => UpdateCapacity(value);
+ }
+
+ public TValue this[TKey key]
+ {
+ get => Get(key);
+ set => Put(key, value);
+ }
+
+ public LruCache(int capacity)
+ {
+ if (capacity < 0) throw new ArgumentOutOfRangeException("capacity must be positive or zero.");
+
+ this._capacity = capacity;
+ }
+
+ public TValue Get(TKey key)
+ {
+ return TryGetValue(key, out var value) ? value : default;
+ }
+ public bool TryGetValue(TKey key, out TValue value)
+ {
+ if (_map.TryGetValue(key, out var node))
+ {
+ _list.Remove(node);
+ _list.AddFirst(node);
+
+ value = node.Value.Value;
+ return true;
+ }
+ else
+ {
+ value = default;
+ return false;
+ }
+ }
+ public void Put(TKey key, TValue value)
+ {
+ if (_capacity == 0) return;
+
+ if (_map.TryGetValue(key, out var node))
+ {
+ node.Value.Value = value;
+
+ _list.Remove(node);
+ _list.AddFirst(node);
+ }
+ else
+ {
+ while (_map.Count >= _capacity)
+ {
+ _map.Remove(_list.Last.Value.Key);
+ _list.RemoveLast();
+ }
+
+ _map.Add(key, _list.AddFirst(new KeyedItem(key, value)));
+ }
+ }
+
+ public void UpdateCapacity(int capacity)
+ {
+ if (capacity < 0) throw new ArgumentOutOfRangeException("capacity must be positive or zero.");
+
+ _capacity = capacity;
+ if (_capacity == 0)
+ {
+ _map.Clear();
+ _list.Clear();
+ }
+ else
+ {
+ while (_map.Count > _capacity)
+ {
+ _map.Remove(_list.Last.Value.Key);
+ _list.RemoveLast();
+ }
+ }
+ }
+}