fix(FloatButton): 修复 BackTop 进度环圆角与按钮轮廓的缝隙

This commit is contained in:
chinboy
2026-09-12 21:30:01 +08:00
parent df1274761a
commit e29ef0b7e0
5 changed files with 144 additions and 48 deletions
@@ -167,19 +167,14 @@ Gallery key:`ExamplesContent` / item `12`
<TextBlock Text="滚动到底部" />
<TextBlock Text="滚动到底部" />
</StackPanel>
<StackPanel Orientation="Horizontal"
Spacing="8"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,16,16,0">
<atom:BackTopFloatButtonHost ButtonType="Default"
IsShowProgress="True"
VisibilityHeight="0" />
<atom:BackTopFloatButtonHost ButtonType="Default"
Shape="Square"
IsShowProgress="True"
VisibilityHeight="0" />
</StackPanel>
<atom:BackTopFloatButtonHost ButtonType="Default"
IsShowProgress="True"
VisibilityHeight="0" />
<atom:BackTopFloatButtonHost ButtonType="Default"
Shape="Square"
IsShowProgress="True"
VisibilityHeight="0"
FloatOffsetX="72" />
</Panel>
</Border>
</atom:ScrollViewer>
+8 -13
View File
@@ -486,19 +486,14 @@ Gallery key:`ExamplesContent` / item `12`
<TextBlock Text="滚动到底部" />
<TextBlock Text="滚动到底部" />
</StackPanel>
<StackPanel Orientation="Horizontal"
Spacing="8"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,16,16,0">
<atom:BackTopFloatButtonHost ButtonType="Default"
IsShowProgress="True"
VisibilityHeight="0" />
<atom:BackTopFloatButtonHost ButtonType="Default"
Shape="Square"
IsShowProgress="True"
VisibilityHeight="0" />
</StackPanel>
<atom:BackTopFloatButtonHost ButtonType="Default"
IsShowProgress="True"
VisibilityHeight="0" />
<atom:BackTopFloatButtonHost ButtonType="Default"
Shape="Square"
IsShowProgress="True"
VisibilityHeight="0"
FloatOffsetX="72" />
</Panel>
</Border>
</atom:ScrollViewer>
@@ -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 形状下环带为同心圆环,扇形与环带的交集即原圆弧进度带。
实现文档不逐行解释私有方法。若某个私有算法成为稳定维护入口,应在本节补充算法不变量,而不是把代码复述为说明书。
@@ -68,6 +68,28 @@ internal class BackTopProgressRing : Control
private IPen? _trackPen;
/// <summary>
/// 计算环的几何:外/中心线/内边界矩形与对应圆角半径。
/// 外边界半径必须等于按钮自身的圆角半径,使环的外缘与按钮轮廓完全重合;
/// 中心线半径为外边界半径减去半线宽,内边界半径为外边界半径减去线宽。
/// </summary>
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;
}
}
/// <summary>
/// 进度环的几何参数。外边界为按钮轮廓(半径等于按钮圆角半径),
/// 中心线与内边界由线宽向内偏移得到。
/// </summary>
internal readonly record struct BackTopProgressRingMetrics(
Rect OuterRect,
Rect CenterlineRect,
Rect InnerRect,
double OuterRadius,
double CenterlineRadius,
double InnerRadius);
@@ -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;
}
}