Avalonia UI 프레임워크는 크로스플랫폼 데스크톱 애플리케이션 개발에 탁월한 선택지 중 하나입니다. 하지만 기본 제공 기능만으로는 디자인적으로 매력적인 요소를 구현하기 어려운 경우가 있습니다. 특히 각도 기반 선형 그라데이션은 Avalonia에서 직접 지원하지 않기 때문에, 이를 커스터마이징하여 애니메이션 효과를 넣으려면 추가적인 수작업이 필요합니다.
본 문서에서는 각도 기반 그라데이션을 동적으로 조절할 수 있는 유틸리티 클래스를 설계하고, 이를 활용해 회전하는 그라데이션 경계선을 가진 시각적으로 인상적인 카드 컴포넌트를 만드는 방법을 설명합니다. 원본은 CodePen의 한 예제에서 영감을 받았으며, 핵심 로직을 C#과 AXAML로 재구성하였습니다.
문제 정의: 각도 기반 그라데이션의 부재
Avalonia의 LinearGradientBrush는 시작점(StartPoint)과 끝점(EndPoint)을 상대 좌표계로 지정해야 하며, 각도 값으로 직접 설정할 수 없습니다. 따라서 사용자가 특정 각도(예: 45도)로 그라데이션 방향을 지정하고 싶을 때는 이 값을 적절한 RelativePoint 쌍으로 변환하는 로직이 필요합니다.
그라데이션 각도 변환기 구현
다음은 주어진 각도(라디안 단위)에 따라 그라데이션의 시작 및 종료점을 계산하는 정적 메서드입니다. 이 메서드는 컨트롤의 경계 사각형을 기준으로 대각선 방향의 교차점을 계산하여 정확한 그라데이션 방향을 생성합니다.
public static class GradientAngleHelper
{
public static readonly AttachedProperty<double> AngleProperty =
AvaloniaProperty.RegisterAttached<GradientAngleHelper, StyledElement, double>(
"Angle",
defaultValue: 0.0,
coerce: UpdateGradient);
private static double UpdateGradient(AvaloniaObject target, double angle)
{
if (target is Border border && border.BorderBrush is LinearGradientBrush brush)
{
ApplyRotation(new Rect(border.Bounds.Size), brush, angle);
}
return angle;
}
public static void SetAngle(IAvaloniaObject element, double value) =>
element.SetValue(AngleProperty, value);
public static double GetAngle(IAvaloniaObject element) =>
element.GetValue(AngleProperty);
private static void ApplyRotation(Rect bounds, LinearGradientBrush brush, double radians)
{
double normalized = radians % (2 * Math.PI);
Point center = bounds.Center;
// 특수 각도 처리
if (IsEquivalent(normalized, Math.PI / 2))
{
brush.StartPoint = new RelativePoint(0.5, 0, RelativeUnit.Relative);
brush.EndPoint = new RelativePoint(0.5, 1, RelativeUnit.Relative);
}
else if (IsEquivalent(normalized, Math.PI))
{
brush.StartPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative);
brush.EndPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative);
}
else if (IsEquivalent(normalized, 3 * Math.PI / 2))
{
brush.StartPoint = new RelativePoint(0.5, 1, RelativeUnit.Relative);
brush.EndPoint = new RelativePoint(0.5, 0, RelativeUnit.Relative);
}
else if (IsEquivalent(normalized, 0) || IsEquivalent(normalized, 2 * Math.PI))
{
brush.StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative);
brush.EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative);
}
else
{
double slope = Math.Tan(radians);
Func<double, double> getY = x => slope * (x - center.X) + center.Y;
Func<double, double> getX = y => (y - center.Y) / slope + center.X;
Point forward = ComputeIntersection(bounds, slope, radians);
Point backward = RotatePoint(forward, Math.PI, bounds);
brush.StartPoint = new RelativePoint(backward.X / bounds.Width, backward.Y / bounds.Height, RelativeUnit.Relative);
brush.EndPoint = new RelativePoint(forward.X / bounds.Width, forward.Y / bounds.Height, RelativeUnit.Relative);
}
}
private static bool IsEquivalent(double a, double b, double tolerance = 1e-6) =>
Math.Abs(a - b) < tolerance;
private static Point ComputeIntersection(Rect rect, double slope, double angle)
{
if (angle > 0 && angle < Math.PI / 2)
{
double yAtRight = getY(slope, rect.Width);
double xAtBottom = getX(slope, rect.Height);
return yAtRight <= rect.Height ? new Point(rect.Width, yAtRight) : new Point(xAtBottom, rect.Height);
}
else if (angle > Math.PI / 2 && angle < Math.PI)
{
double yAtLeft = getY(slope, 0);
double xAtBottom = getX(slope, rect.Height);
return yAtLeft <= rect.Height ? new Point(0, yAtLeft) : new Point(xAtBottom, rect.Height);
}
else if (angle > Math.PI && angle < 3 * Math.PI / 2)
{
double yAtLeft = getY(slope, 0);
double xAtTop = getX(slope, 0);
return yAtLeft >= 0 ? new Point(0, yAtLeft) : new Point(xAtTop, 0);
}
else
{
double yAtRight = getY(slope, rect.Width);
double xAtTop = getX(slope, 0);
return yAtRight >= 0 ? new Point(rect.Width, yAtRight) : new Point(xAtTop, 0);
}
}
private static double getY(double slope, double x, Point center) =>
slope * (x - center.X) + center.Y;
private static double getX(double slope, double y, Point center) =>
(y - center.Y) / slope + center.X;
private static Point RotatePoint(Point p, double rotation, Rect bounds)
{
Point center = bounds.Center;
double cos = Math.Cos(rotation), sin = Math.Sin(rotation);
double dx = p.X - center.X, dy = p.Y - center.Y;
return new Point(center.X + dx * cos - dy * sin, center.Y + dx * sin + dy * cos);
}
}
AXAML 마크업 구성
UI는 두 개의 중첩된 Border로 구성됩니다. 외부 Border는 흐림 효과와 함께 배경 그라데이션을 표현하며, 내부 Border는 실제 카드 본체와 테두리 그라데이션을 담당합니다.
<Panel>
<Border Name="BackgroundGlow"
Width="{Binding ElementName=CardBorder, Path=Width}"
Height="{Binding ElementName=CardBorder, Path=Height}"
CornerRadius="{Binding ElementName=CardBorder, Path=CornerRadius}">
<Border.Background>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<GradientStop Offset="0" Color="#5ddcff"/>
<GradientStop Offset="0.43" Color="#3c67e3"/>
<GradientStop Offset="1" Color="#4e00c2"/>
</LinearGradientBrush>
</Border.Background>
<Border.Effect>
<BlurEffect Radius="100"/>
</Border.Effect>
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="0.8" ScaleY="0.8"/>
<TranslateTransform X="0" Y="30"/>
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border Name="CardBorder"
Width="200"
Height="300"
Background="#191c29"
BorderThickness="3"
CornerRadius="10">
<Border.BorderBrush>
<LinearGradientBrush x:Name="CardGradient">
<GradientStop Offset="0" Color="#5ddcff"/>
<GradientStop Offset="0.43" Color="#3c67e3"/>
<GradientStop Offset="1" Color="#4e00c2"/>
</LinearGradientBrush>
</Border.BorderBrush>
<!-- Card content goes here -->
</Border>
</Panel>
애니메이션 적용
클릭 이벤트에서 무한 반복 회전 애니메이션을 실행하도록 설정할 수 있습니다. 다음 코드는 2초 동안 0에서 360도(2π 라디안)까지 그라데이션을 회전시키는 예시입니다.
private async void OnAnimateClick(object? sender, RoutedEventArgs e)
{
var brush = new LinearGradientBrush
{
GradientStops =
{
new GradientStop(Color.Parse("#5ddcff"), 0),
new GradientStop(Color.Parse("#3c67e3"), 0.43),
new GradientStop(Color.Parse("#4e00c2"), 1)
}
};
CardBorder.BorderBrush = brush;
var animation = new Animation
{
Duration = TimeSpan.FromSeconds(2),
IterationCount = IterationCount.Infinite
};
animation.Children.Add(new KeyFrame
{
Cue = new Cue(0),
Setters = { new Setter(GradientAngleHelper.AngleProperty, 0.0) }
});
animation.Children.Add(new KeyFrame
{
Cue = new Cue(1),
Setters = { new Setter(GradientAngleHelper.AngleProperty, 2 * Math.PI) }
});
await animation.RunAsync(CardBorder);
}
결과
위와 같은 구조를 통해, Avalonia에서도 CSS 기반 웹 애니메이션에서나 볼 법한 부드럽고 다이내믹한 그라데이션 효과를 구현할 수 있습니다. 특히 AttachedProperty를 사용함으로써 XAML 내에서 선언적으로 애니메이션을 제어할 수 있어 유지보수성과 확장성이 크게 향상됩니다.