From e29ef0b7e046504386507cd787acdfd44f1aabfe Mon Sep 17 00:00:00 2001 From: chinboy Date: Sat, 12 Sep 2026 21:30:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(FloatButton):=20=E4=BF=AE=E5=A4=8D=20BackTo?= =?UTF-8?q?p=20=E8=BF=9B=E5=BA=A6=E7=8E=AF=E5=9C=86=E8=A7=92=E4=B8=8E?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E8=BD=AE=E5=BB=93=E7=9A=84=E7=BC=9D=E9=9A=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../llms/controls/float-button/index-cn.md | 21 ++--- docs/AI/generated/llms/llms-full-cn.txt | 21 ++--- .../general/float-button/implementation.md | 3 +- .../FloatButton/BackTopProgressRing.cs | 67 +++++++++++----- .../BackTopFloatButtonProgressTests.cs | 80 +++++++++++++++++++ 5 files changed, 144 insertions(+), 48 deletions(-) diff --git a/docs/AI/generated/llms/controls/float-button/index-cn.md b/docs/AI/generated/llms/controls/float-button/index-cn.md index 6382bec72..63bca6ccc 100644 --- a/docs/AI/generated/llms/controls/float-button/index-cn.md +++ b/docs/AI/generated/llms/controls/float-button/index-cn.md @@ -167,19 +167,14 @@ Gallery key:`ExamplesContent` / item `12` - - - - + + diff --git a/docs/AI/generated/llms/llms-full-cn.txt b/docs/AI/generated/llms/llms-full-cn.txt index ef7f81667..6a54347dd 100644 --- a/docs/AI/generated/llms/llms-full-cn.txt +++ b/docs/AI/generated/llms/llms-full-cn.txt @@ -486,19 +486,14 @@ Gallery key:`ExamplesContent` / item `12` - - - - + + diff --git a/docs/controls/desktop/general/float-button/implementation.md b/docs/controls/desktop/general/float-button/implementation.md index 45d223e40..24ccf04a7 100644 --- a/docs/controls/desktop/general/float-button/implementation.md +++ b/docs/controls/desktop/general/float-button/implementation.md @@ -178,8 +178,9 @@ FloatButton 的交互事件应从输入源收敛到控件级语义事件: - 进度计算:`maxScroll = max(Extent.Height - Viewport.Height, 0)`,`ScrollProgress = maxScroll > 0 ? Clamp(Offset.Y / maxScroll, 0, 1) : 0`。进度只在 `Target` 的 `ScrollChanged` 与控件 `OnLoaded` 两个入口更新;Avalonia `ScrollChanged` 在 extent/viewport 变化时同样触发,内容尺寸变化(等效 resize 场景)已被覆盖,不引入额外的 SizeChanged 监听。 - 绘制实现:`BackTopProgressRing` 自定义 `Render` 绘制轨道与进度指示。轨道是圆角矩形描边,`CornerRadius` 为高度一半(Circle 形状由基类尺寸同步设置)时即为整圆;进度指示是以环中心为顶点、半径取环矩形半对角线的扇形填充,从 12 点方向(-90°)顺时针扫 `360 × progress` 度(进度满 100% 时扇形退化,直接用进度色填充整个环带),再裁剪到环带。 +- 环几何不变量:`CalculateRingMetrics` 统一推导三个边界——外边界半径等于按钮自身圆角半径(与按钮轮廓完全重合,圆角处不留缝隙),中心线半径 = 外边界半径 − 半线宽,内边界半径 = 外边界半径 − 线宽;对应矩形依次为 `bounds`、`bounds.Deflate(半线宽)`、`bounds.Deflate(线宽)`。`CornerRadius` 是**外边界**的半径语义,不是中心线半径:把它当中心线使用会让外缘比按钮轮廓大出半线宽,在四个圆角处露出背景与环之间的缝隙(圆形因半径被 clamp 到尺寸一半而恰好不暴露)。 - ControlTheme 兜底理由:扇形角度是随滚动连续变化的运行时值,ControlTheme selector/Setter 无法表达,故按主题绑定优先约束的兜底条款采用自定义 Render;线宽、颜色与显隐等静态契约仍由 `BackTopFloatButtonTheme.axaml` 注入——`StrokeThickness` 取 `LineWidthBold`、`TrackBrush` 取 `ColorBorderSecondary`、`IndicatorBrush` 取 `ColorPrimary`,显隐由 `^[IsShowProgress=True] /template/ atomc|BackTopProgressRing#ProgressRing` selector 控制,默认 `IsVisible=False`。 -- Square 环带裁剪:进度扇形通过 `PushGeometryClip` 裁剪到「外圆角矩形 − 内圆角矩形」的 `CombinedGeometry`(`Exclude`)环带,Circle 与 Square 复用同一渲染路径。扇形半径取半对角线以保证覆盖环带最外点;环带以 `ringRect` 为中心线、厚度为线宽:外边界为 `ringRect.Inflate(半线宽)`、内边界为 `ringRect.Deflate(半线宽)`,与轨道描边带重合。Circle 形状下环带为同心圆环,扇形与环带的交集即原圆弧进度带。 +- Square 环带裁剪:进度扇形通过 `PushGeometryClip` 裁剪到「外圆角矩形 − 内圆角矩形」的 `CombinedGeometry`(`Exclude`)环带,Circle 与 Square 复用同一渲染路径。扇形半径取半对角线以保证覆盖环带最外点。Circle 形状下环带为同心圆环,扇形与环带的交集即原圆弧进度带。 实现文档不逐行解释私有方法。若某个私有算法成为稳定维护入口,应在本节补充算法不变量,而不是把代码复述为说明书。 diff --git a/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs b/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs index 58b5e761b..06316a31a 100644 --- a/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs +++ b/src/AtomUI.Controls/FloatButton/BackTopProgressRing.cs @@ -68,6 +68,28 @@ internal class BackTopProgressRing : Control private IPen? _trackPen; + /// + /// 计算环的几何:外/中心线/内边界矩形与对应圆角半径。 + /// 外边界半径必须等于按钮自身的圆角半径,使环的外缘与按钮轮廓完全重合; + /// 中心线半径为外边界半径减去半线宽,内边界半径为外边界半径减去线宽。 + /// + internal static BackTopProgressRingMetrics CalculateRingMetrics( + Size size, double cornerRadius, double thickness) + { + var outerRect = new Rect(size); + var centerlineRect = outerRect.Deflate(thickness / 2); + var innerRect = outerRect.Deflate(thickness); + var halfThickness = thickness / 2; + + var outerLimit = Math.Min(outerRect.Width, outerRect.Height) / 2; + var outerRadius = Math.Clamp(cornerRadius, 0d, outerLimit); + var centerlineRadius = Math.Max(0d, outerRadius - halfThickness); + var innerRadius = Math.Max(0d, outerRadius - thickness); + + return new BackTopProgressRingMetrics(outerRect, centerlineRect, innerRect, + outerRadius, centerlineRadius, innerRadius); + } + public override void Render(DrawingContext context) { var bounds = new Rect(Bounds.Size); @@ -77,15 +99,14 @@ internal class BackTopProgressRing : Control return; } - // 描边带完全落在控件边界内,等价 antd border-box 内的边框环 - var ringRect = bounds.Deflate(thickness / 2); - var radius = EffectiveRadius(ringRect); + var metrics = CalculateRingMetrics(bounds.Size, CornerRadius.TopLeft, thickness); PenUtils.TryModifyOrCreate(ref _trackPen, TrackBrush, thickness); if (_trackPen is not null) { - // CornerRadius=尺寸一半(Circle 由 AbstractFloatButton.OnSizeChanged 设置)时即为圆 - context.DrawRectangle(null, _trackPen, ringRect, radius, radius); + // 轨道描边以中心线为基准;外缘与按钮轮廓重合,两种形状共用同一路径 + context.DrawRectangle(null, _trackPen, metrics.CenterlineRect, + metrics.CenterlineRadius, metrics.CenterlineRadius); } var sweepAngle = 360d * Math.Clamp(Progress, 0d, 1d); @@ -95,14 +116,11 @@ internal class BackTopProgressRing : Control } // 进度扇形填充裁剪到「外圆角矩形−内圆角矩形」环带,Circle/Square 统一路径。 - // 环带以 ringRect 为中心线、厚度为线宽,与轨道描边带重合: - // 外边界 = ringRect.Inflate(半线宽),内边界 = ringRect.Deflate(半线宽)。 - // 指示器必须是扇形填充而非 ringRect 的内切圆弧描边:Square 形状下圆角方向的 + // 指示器必须是扇形填充而非环矩形的内切圆弧描边:Square 形状下圆角方向的 // 内切圆弧半径小于外边界,会落入内孔几何被裁掉,进度环退化为四段侧边短线 - var halfThickness = thickness / 2; - var bandGeometry = new CombinedGeometry(GeometryCombineMode.Exclude, - BuildRoundedRectGeometry(ringRect.Inflate(halfThickness), radius + halfThickness), - BuildRoundedRectGeometry(ringRect.Deflate(halfThickness), Math.Max(0d, radius - halfThickness))); + var bandGeometry = new CombinedGeometry(GeometryCombineMode.Exclude, + BuildRoundedRectGeometry(metrics.OuterRect, metrics.OuterRadius), + BuildRoundedRectGeometry(metrics.InnerRect, metrics.InnerRadius)); if (sweepAngle >= 360d) { @@ -111,12 +129,12 @@ internal class BackTopProgressRing : Control return; } - // 以 ringRect.Center 为顶点的扇形,半径取半对角线以覆盖环带最外点, + // 以环中心为顶点的扇形,半径取半对角线以覆盖环带最外点, // 从 -90°(12 点方向)顺时针扫 sweepAngle。 // 角度→点换算与 CommonShapeBuilder.GetRingPoint 同约定:x = cx + r·cos(deg),y = cy + r·sin(deg) - var center = ringRect.Center; - var sectorRadius = Math.Sqrt(ringRect.Width * ringRect.Width / 4 - + ringRect.Height * ringRect.Height / 4); + var center = metrics.CenterlineRect.Center; + var sectorRadius = Math.Sqrt(metrics.CenterlineRect.Width * metrics.CenterlineRect.Width / 4 + + metrics.CenterlineRect.Height * metrics.CenterlineRect.Height / 4); var startPoint = new Point( center.X + sectorRadius * Math.Cos(MathUtils.Deg2Rad(-90d)), @@ -141,11 +159,6 @@ internal class BackTopProgressRing : Control } } - private double EffectiveRadius(Rect rect) - { - return Math.Clamp(CornerRadius.TopLeft, 0d, Math.Min(rect.Width, rect.Height) / 2); - } - private static StreamGeometry BuildRoundedRectGeometry(Rect rect, double radius) { var geometry = new StreamGeometry(); @@ -183,3 +196,15 @@ internal class BackTopProgressRing : Control return geometry; } } + +/// +/// 进度环的几何参数。外边界为按钮轮廓(半径等于按钮圆角半径), +/// 中心线与内边界由线宽向内偏移得到。 +/// +internal readonly record struct BackTopProgressRingMetrics( + Rect OuterRect, + Rect CenterlineRect, + Rect InnerRect, + double OuterRadius, + double CenterlineRadius, + double InnerRadius); diff --git a/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs b/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs index 89626176e..2a2b8a013 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/FloatButton/BackTopFloatButtonProgressTests.cs @@ -1,6 +1,7 @@ using AtomUI.Controls; using AtomUI.Controls.Commons; using AtomUI.Controls.Primitives; +using AtomUI.Utils; using Avalonia; using Avalonia.Controls; using Avalonia.Threading; @@ -293,4 +294,83 @@ public class BackTopFloatButtonProgressTests window.Close(); } } + + [Theory] + [InlineData(40d, 8d)] // Square:CornerRadius = BorderRadiusLG + [InlineData(40d, 20d)] // Circle:CornerRadius = Height / 2 + public void BackTop_Ring_OuterEdge_Should_Match_Button_CornerRadius(double size, double cornerRadius) + { + // 环的外缘必须与按钮轮廓完全重合(半径 = 按钮圆角半径), + // 否则圆角处会露出背景与环之间的缝隙。 + var metrics = BackTopProgressRing.CalculateRingMetrics(new Size(size, size), cornerRadius, 2d); + + metrics.OuterRadius.ShouldBe(cornerRadius, 0.0001d); + metrics.CenterlineRadius.ShouldBe(cornerRadius - 1d, 0.0001d); + metrics.InnerRadius.ShouldBe(cornerRadius - 2d, 0.0001d); + metrics.OuterRect.ShouldBe(new Rect(new Size(size, size))); + } + + [Theory] + [InlineData(40d, 8d)] // Square + [InlineData(40d, 20d)] // Circle + public void BackTop_Ring_Band_Should_Cover_Button_Outline(double size, double cornerRadius) + { + // 沿按钮轮廓的四个圆角采样:这些点必须落在环带内(在外边界内、不在内孔里)。 + // 这是圆角缝隙的直接几何判据——缝隙正是「按钮轮廓上未被环带覆盖」的区域。 + // 使用自建圆角矩形包含判据而非 geometry.FillContains:后者在 headless 环境 + // 下对 StreamGeometry 的命中测试不可靠(几何中心点都会被判为外部)。 + const double thickness = 2d; + var metrics = BackTopProgressRing.CalculateRingMetrics(new Size(size, size), cornerRadius, thickness); + + // 稍向内采样 0.05px,避免恰好落在边界上的浮点歧义。 + // 每个圆角只采样其「向外」的 90° 象限:这些点才真正落在按钮轮廓上。 + var sampleRadius = cornerRadius - 0.05d; + var cornerSamples = new[] + { + (Center: new Point(cornerRadius, cornerRadius), StartAngle: 180d), // 左上 + (Center: new Point(size - cornerRadius, cornerRadius), StartAngle: 270d), // 右上 + (Center: new Point(size - cornerRadius, size - cornerRadius), StartAngle: 0d), // 右下 + (Center: new Point(cornerRadius, size - cornerRadius), StartAngle: 90d) // 左下 + }; + + foreach (var (cornerCenter, startAngle) in cornerSamples) + { + for (var offset = 0d; offset <= 90d; offset += 10d) + { + var rad = MathUtils.Deg2Rad(startAngle + offset); + var point = new Point( + cornerCenter.X + sampleRadius * Math.Cos(rad), + cornerCenter.Y + sampleRadius * Math.Sin(rad)); + + InsideRoundedRect(metrics.OuterRect, metrics.OuterRadius, point) + .ShouldBeTrue($"outline point {point} must be covered by ring outer boundary"); + InsideRoundedRect(metrics.InnerRect, metrics.InnerRadius, point) + .ShouldBeFalse($"outline point {point} must not fall inside ring inner hole"); + } + } + } + + private static bool InsideRoundedRect(Rect rect, double radius, Point point) + { + const double epsilon = 1e-9; + if (point.X < rect.Left - epsilon || point.X > rect.Right + epsilon || + point.Y < rect.Top - epsilon || point.Y > rect.Bottom + epsilon) + { + return false; + } + + radius = Math.Clamp(radius, 0d, Math.Min(rect.Width, rect.Height) / 2); + var centerX = Math.Clamp(point.X, rect.Left + radius, rect.Right - radius); + var centerY = Math.Clamp(point.Y, rect.Top + radius, rect.Bottom - radius); + var inCornerX = point.X < rect.Left + radius || point.X > rect.Right - radius; + var inCornerY = point.Y < rect.Top + radius || point.Y > rect.Bottom - radius; + if (inCornerX && inCornerY) + { + var dx = point.X - centerX; + var dy = point.Y - centerY; + return dx * dx + dy * dy <= radius * radius + epsilon; + } + + return true; + } }