如何围绕StackLayout创建方向变色的环绕加载旋转动画?
实现围绕StackLayout的彩色旋转加载指示器方案
我来给你分享一个可行的实现思路,基于.NET MAUI(如果是Xamarin.Forms也可以类似调整),核心是通过自定义ContentView包裹StackLayout,在其上层绘制动态的环绕动画:
核心思路
- 用自定义
ContentView封装StackLayout,保留原布局的所有功能,同时叠加动画绘制层 - 循环动画控制指示器的位置进度,触发重绘
- 沿StackLayout的边框路径绘制移动的指示器,颜色随位置动态变化,同时通过多层半透明线段实现轨迹渐隐效果
完整代码实现
自定义LoadingStackLayout控件
using Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; namespace YourAppNamespace; public class LoadingStackLayout : ContentView { private double _animationProgress; private readonly Animation _loadingAnimation; private readonly Paint _basePaint = new() { Style = PaintStyle.Stroke, StrokeCap = StrokeCap.Round }; // 暴露自定义属性,让控件更灵活 public static readonly BindableProperty StrokeWidthProperty = BindableProperty.Create(nameof(StrokeWidth), typeof(double), typeof(LoadingStackLayout), 4.0); public static readonly BindableProperty IndicatorLengthProperty = BindableProperty.Create(nameof(IndicatorLength), typeof(double), typeof(LoadingStackLayout), 20.0); public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create(nameof(CornerRadius), typeof(double), typeof(LoadingStackLayout), 8.0); public double StrokeWidth { get => (double)GetValue(StrokeWidthProperty); set => SetValue(StrokeWidthProperty, value); } public double IndicatorLength { get => (double)GetValue(IndicatorLengthProperty); set => SetValue(IndicatorLengthProperty, value); } public double CornerRadius { get => (double)GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } public LoadingStackLayout() { // 内部嵌套StackLayout,保留原布局功能 var stackLayout = new StackLayout(); stackLayout.SetBinding(StackLayout.ChildrenProperty, new Binding(nameof(Content), source: this)); base.Content = stackLayout; // 创建循环动画,每2秒完成一圈 _loadingAnimation = new Animation(v => { _animationProgress = v; InvalidateSurface(); }, 0, 1, Easing.Linear); _loadingAnimation.Commit(this, "LoadingLoop", length: 2000, repeat: () => true); } protected override void OnPaintSurface(SurfacePaintEventArgs args) { base.OnPaintSurface(args); if (Content == null) return; var canvas = args.Canvas; var layoutBounds = Content.Bounds; var cornerRadius = (float)CornerRadius; var strokeWidth = (float)StrokeWidth; var indicatorLength = (float)IndicatorLength; // 计算边框总周长(包含圆角弧长) var straightEdgeLength = (layoutBounds.Width + layoutBounds.Height - 4 * cornerRadius) * 2; var arcLength = 2 * Math.PI * cornerRadius; var totalPerimeter = straightEdgeLength + 2 * arcLength; // 当前指示器的起始位置(按周长比例计算) var startDistance = _animationProgress * totalPerimeter; // 生成边框的完整路径 var fullPath = new PathF(); fullPath.AddRoundedRectangle(layoutBounds, cornerRadius); // 绘制渐隐轨迹:通过多层透明度递减的线段模拟拖尾效果 for (int i = 0; i < 10; i++) { var offset = (startDistance - i * 8 + totalPerimeter) % totalPerimeter; var segmentPath = GetPathSegment(fullPath, offset, indicatorLength - i * 1.5f); if (segmentPath == null) continue; // 根据进度计算当前色相,实现颜色随方向变化 var hue = (_animationProgress * 360 - i * 10) % 360; var trailColor = Color.FromHsla(hue / 360, 0.8, 0.6, 1 - i * 0.1f); _basePaint.Color = trailColor; _basePaint.StrokeWidth = strokeWidth - i * 0.3f; canvas.DrawPath(segmentPath, _basePaint); } // 绘制当前高亮的指示器 var currentSegment = GetPathSegment(fullPath, startDistance, indicatorLength); if (currentSegment != null) { var currentHue = (_animationProgress * 360) % 360; var highlightColor = Color.FromHsla(currentHue / 360, 0.8, 0.6); _basePaint.Color = highlightColor; _basePaint.StrokeWidth = strokeWidth; canvas.DrawPath(currentSegment, _basePaint); } } // 从完整边框路径中截取指定距离的线段 private PathF? GetPathSegment(PathF fullPath, double startDistance, float segmentLength) { var bounds = fullPath.Bounds; var cornerRadius = (float)CornerRadius; var sideLengths = new[] { bounds.Width - 2 * cornerRadius, // 上边直线段 bounds.Height - 2 * cornerRadius, // 右边直线段 bounds.Width - 2 * cornerRadius, // 下边直线段 bounds.Height - 2 * cornerRadius // 左边直线段 }; double cumulativeLength = 0; int currentSide = 0; // 判断当前起始距离落在哪个边/圆角上 while (currentSide < 4) { var sideTotalLength = sideLengths[currentSide] + Math.PI * cornerRadius; if (startDistance <= cumulativeLength + sideTotalLength) break; cumulativeLength += sideTotalLength; currentSide++; } if (currentSide >= 4) return null; var remainingDistance = startDistance - cumulativeLength; var segmentPath = new PathF(); switch (currentSide) { case 0: // 上边 var topStartX = bounds.Left + cornerRadius + (float)remainingDistance; var topEndX = topStartX + segmentLength; if (topEndX > bounds.Right - cornerRadius) { // 进入右上角圆角 var arcLength = topEndX - (bounds.Right - cornerRadius); segmentPath.MoveTo(topStartX, bounds.Top); segmentPath.LineTo(bounds.Right - cornerRadius, bounds.Top); segmentPath.AddArc( new RectF(bounds.Right - 2 * cornerRadius, bounds.Top, 2 * cornerRadius, 2 * cornerRadius), 180, (float)(arcLength / (Math.PI * cornerRadius) * 90)); } else { segmentPath.MoveTo(topStartX, bounds.Top); segmentPath.LineTo(topEndX, bounds.Top); } break; case 1: // 右边 var rightStartY = bounds.Top + cornerRadius + (float)remainingDistance; var rightEndY = rightStartY + segmentLength; if (rightEndY > bounds.Bottom - cornerRadius) { var arcLength = rightEndY - (bounds.Bottom - cornerRadius); segmentPath.MoveTo(bounds.Right, rightStartY); segmentPath.LineTo(bounds.Right, bounds.Bottom - cornerRadius); segmentPath.AddArc( new RectF(bounds.Right - 2 * cornerRadius, bounds.Bottom - 2 * cornerRadius, 2 * cornerRadius, 2 * cornerRadius), 270, (float)(arcLength / (Math.PI * cornerRadius) * 90)); } else { segmentPath.MoveTo(bounds.Right, rightStartY); segmentPath.LineTo(bounds.Right, rightEndY); } break; case 2: // 下边 var bottomStartX = bounds.Right - cornerRadius - (float)remainingDistance; var bottomEndX = bottomStartX - segmentLength; if (bottomEndX < bounds.Left + cornerRadius) { var arcLength = (bounds.Left + cornerRadius) - bottomEndX; segmentPath.MoveTo(bottomStartX, bounds.Bottom); segmentPath.LineTo(bounds.Left + cornerRadius, bounds.Bottom); segmentPath.AddArc( new RectF(bounds.Left, bounds.Bottom - 2 * cornerRadius, 2 * cornerRadius, 2 * cornerRadius), 0, (float)(arcLength / (Math.PI * cornerRadius) * 90)); } else { segmentPath.MoveTo(bottomStartX, bounds.Bottom); segmentPath.LineTo(bottomEndX, bounds.Bottom); } break; case 3: // 左边 var leftStartY = bounds.Bottom - cornerRadius - (float)remainingDistance; var leftEndY = leftStartY - segmentLength; if (leftEndY < bounds.Top + cornerRadius) { var arcLength = (bounds.Top + cornerRadius) - leftEndY; segmentPath.MoveTo(bounds.Left, leftStartY); segmentPath.LineTo(bounds.Left, bounds.Top + cornerRadius); segmentPath.AddArc( new RectF(bounds.Left, bounds.Top, 2 * cornerRadius, 2 * cornerRadius), 90, (float)(arcLength / (Math.PI * cornerRadius) * 90)); } else { segmentPath.MoveTo(bounds.Left, leftStartY); segmentPath.LineTo(bounds.Left, leftEndY); } break; } return segmentPath; } }
在XAML中使用控件
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:local="clr-namespace:YourAppNamespace" x:Class="YourAppNamespace.MainPage"> <local:LoadingStackLayout StrokeWidth="5" IndicatorLength="25" CornerRadius="10"> <!-- 这里放你原来的StackLayout内容 --> <Label Text="加载中..." HorizontalOptions="Center" VerticalOptions="Center" FontSize="18"/> <Button Text="测试按钮" Margin="0,20,0,0" HorizontalOptions="Center"/> </local:LoadingStackLayout> </ContentPage>
关键功能说明
- 颜色动态变化:通过HSL颜色模型,根据动画进度计算色相,实现指示器沿路径移动时的颜色渐变
- 轨迹渐隐效果:循环绘制10层偏移线段,每层透明度和线条宽度递减,模拟拖尾淡出的视觉效果
- 圆角适配:支持自定义圆角半径,自动处理圆角处的路径过渡
- 可配置性:通过BindableProperty暴露线条宽度、指示器长度、圆角半径等参数,方便自定义样式
内容的提问来源于stack exchange,提问作者Iliut Andrei
相关产品推荐
相关产品推荐

