mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-22 09:23:53 +08:00
fix(FloatButton): 修复 BackTop 进度环圆角与按钮轮廓的缝隙
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user