diff --git a/Toolkit.UI.Controls.Avalonia/AsyncImage/AsyncImage.cs b/Toolkit.UI.Controls.Avalonia/AsyncImage/AsyncImage.cs new file mode 100644 index 0000000..bacad06 --- /dev/null +++ b/Toolkit.UI.Controls.Avalonia/AsyncImage/AsyncImage.cs @@ -0,0 +1,8 @@ +namespace Toolkit.UI.Controls.Avalonia; + +public class AsyncImage : + global::Avalonia.Labs.Controls.AsyncImage +{ + protected override Type StyleKeyOverride => + typeof(global::Avalonia.Labs.Controls.AsyncImage); +} diff --git a/Toolkit.UI.Controls.Avalonia/BlurBehind/BlurBehind.cs b/Toolkit.UI.Controls.Avalonia/BlurBehind/BlurBehind.cs new file mode 100644 index 0000000..8284e95 --- /dev/null +++ b/Toolkit.UI.Controls.Avalonia/BlurBehind/BlurBehind.cs @@ -0,0 +1,115 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Avalonia.Styling; +using SkiaSharp; + +namespace Toolkit.UI.Controls.Avalonia; + +public class BlurBehind : + Control +{ + public static readonly StyledProperty MaterialProperty = + AvaloniaProperty.Register("Material"); + + public static readonly ImmutableExperimentalAcrylicMaterial DefaultAcrylicMaterialDark = + (ImmutableExperimentalAcrylicMaterial)new ExperimentalAcrylicMaterial() + { + MaterialOpacity = 0.25, + TintColor = Colors.Black, + TintOpacity = 0.7, + PlatformTransparencyCompensationLevel = 0 + }.ToImmutable(); + + public static readonly ImmutableExperimentalAcrylicMaterial DefaultAcrylicMaterialLight = + (ImmutableExperimentalAcrylicMaterial)new ExperimentalAcrylicMaterial() + { + MaterialOpacity = 0.0, + TintColor = Colors.White, + TintOpacity = 0.3, + PlatformTransparencyCompensationLevel = 0 + }.ToImmutable(); + + static BlurBehind() + { + AffectsRender(MaterialProperty); + } + + public ExperimentalAcrylicMaterial Material + { + get => GetValue(MaterialProperty); + set => SetValue(MaterialProperty, value); + } + + public override void Render(DrawingContext context) + { + ImmutableExperimentalAcrylicMaterial material = Material is not null + ? (ImmutableExperimentalAcrylicMaterial)Material.ToImmutable() + : Application.Current?.ActualThemeVariant == ThemeVariant.Dark ? DefaultAcrylicMaterialDark : DefaultAcrylicMaterialLight; + + context.Custom(new BlurBehindRenderOperation(material, new Rect(default, Bounds.Size))); + } + + private class BlurBehindRenderOperation(ImmutableExperimentalAcrylicMaterial material, + Rect bounds) : ICustomDrawOperation + { + private readonly Rect bounds = bounds; + private readonly ImmutableExperimentalAcrylicMaterial material = material; + + public Rect Bounds => bounds.Inflate(4); + + public void Dispose() + { + + } + + public bool Equals(ICustomDrawOperation? other) => + other is BlurBehindRenderOperation behindRenderOperation && + behindRenderOperation.bounds == bounds && behindRenderOperation.material.Equals(material); + + public bool HitTest(Point point) => bounds.Contains(point); + + public void Render(ImmediateDrawingContext context) + { + if (context.TryGetFeature() is ISkiaSharpApiLeaseFeature leaseFeature) + { + using ISkiaSharpApiLease? lease = leaseFeature.Lease(); + if (lease.SkCanvas is SKCanvas canvas) + { + if (canvas.TotalMatrix.TryInvert(out SKMatrix currentInvertedTransform)) + { + if (lease.SkSurface is SKSurface surface) + { + using SKImage backgroundSnapshot = surface.Snapshot(); + using SKShader backdropShader = SKShader.CreateImage(backgroundSnapshot, SKShaderTileMode.Clamp, + SKShaderTileMode.Clamp, currentInvertedTransform); + + using SKSurface blurred = SKSurface.Create(lease.GrContext, false, + new SKImageInfo((int)Math.Ceiling(bounds.Width), (int)Math.Ceiling(bounds.Height), + SKImageInfo.PlatformColorType, SKAlphaType.Premul)); + + using (SKImageFilter filter = SKImageFilter.CreateBlur(8, 8, SKShaderTileMode.Clamp)) + using (SKPaint blurPaint = new() { Shader = backdropShader, ImageFilter = filter }) + blurred.Canvas.DrawRect(5, 5, (float)bounds.Width - 20, (float)bounds.Height - 20, blurPaint); + + using SKImage blurSnap = blurred.Snapshot(); + using SKShader blurSnapShader = SKShader.CreateImage(blurSnap); + using SKPaint blurSnapPaint = new() + { + Shader = blurSnapShader, + IsAntialias = true + }; + + canvas.DrawRect(0, 0, (float)bounds.Width, (float)bounds.Height, blurSnapPaint); + } + + } + } + } + } + } +} + diff --git a/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselView.axaml b/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselView.axaml new file mode 100644 index 0000000..80f3f5b --- /dev/null +++ b/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselView.axaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselView.cs b/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselView.cs new file mode 100644 index 0000000..23de510 --- /dev/null +++ b/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselView.cs @@ -0,0 +1,313 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Animations; +using System.Collections.Specialized; +using System.Numerics; + +namespace Toolkit.UI.Controls.Avalonia; + +public class CarouselView : + ItemsControl +{ + private readonly TimeSpan animationDuration = TimeSpan.FromMilliseconds(500); + private readonly List animations = []; + private readonly int columnCount = 5; + private readonly List itemVisuals = []; + private readonly ScopedBatchHelper scopedBatch = new(); + private readonly double spacing = 12; + private Compositor? compositor; + private Grid? container; + private Vector3D finalOffset; + private float horizontalDelta; + private Rectangle? indicator; + private Vector3DKeyFrameAnimation? indicatorAnimation; + private CompositionVisual? indicatorVisual; + private bool isAnimating; + private bool isPressed; + private List? items; + private Point? lastPosition; + private int newIndex; + private int SelectedIndex; + private Point? startPosition; + private CompositionVisual? touchAreaVisual; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs args) + { + container = args.NameScope.Get("Container"); + if (container is not null) + { + items = container.Children.OfType().ToList(); + foreach (Border item in items) + { + if (item.Child is CarouselViewItem contentControl) + { + contentControl.ContentTemplate = ItemTemplate; + } + } + } + + indicator = args.NameScope.Get("Indicator"); + if (indicator is not null) + { + indicatorVisual = ElementComposition.GetElementVisual(indicator); + } + + ItemsView.CollectionChanged -= OnCollectionChanged; + ItemsView.CollectionChanged += OnCollectionChanged; + + base.OnApplyTemplate(args); + } + + protected override void OnSizeChanged(SizeChangedEventArgs args) + { + base.OnSizeChanged(args); + ArrangeItems(newIndex, isAnimating: false); + } + + protected override void OnLoaded(RoutedEventArgs args) + { + if (container is not null + && items is not null + && indicator is not null) + { + indicatorVisual = ElementComposition.GetElementVisual(indicator); + touchAreaVisual = ElementComposition.GetElementVisual(container); + if (touchAreaVisual is not null) + { + compositor = touchAreaVisual.Compositor; + } + + itemVisuals.Clear(); + foreach (Border item in items) + { + if (ElementComposition.GetElementVisual(item) is CompositionVisual visual) + { + itemVisuals.Add(visual); + } + } + + ArrangeItems(newIndex); + } + + base.OnLoaded(args); + } + + protected override void OnPointerMoved(PointerEventArgs args) + { + if (isPressed && indicatorVisual is not null && startPosition.HasValue) + { + lastPosition = args.GetPosition(container); + horizontalDelta = (float)(lastPosition.Value.X - startPosition.Value.X); + + indicatorVisual.Offset = new Vector3(horizontalDelta, 0.0f, 0.0f); + } + + base.OnPointerMoved(args); + } + + protected override void OnPointerPressed(PointerPressedEventArgs args) + { + if (!isPressed && indicatorVisual is not null) + { + if (!isAnimating) + { + horizontalDelta = 0; + + isPressed = true; + startPosition = args.GetPosition(container); + + indicatorVisual.Offset = new Vector3(horizontalDelta, 0.0f, 0.0f); + PrepareAnimations(); + + for (int i = 0; i < itemVisuals.Count; i++) + { + itemVisuals[i].StartAnimation("Offset", animations[i]); + } + } + } + + base.OnPointerPressed(args); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs args) + { + if (isPressed && container is not null + && items is not null + && indicatorVisual is not null) + { + isPressed = false; + + double itemWidth = items[0].Bounds.Width; + double threshold = itemWidth / 3; + + int oldSelectedIndex = newIndex; + double offset = indicatorVisual.Offset.X; + + if (offset <= -threshold) + { + newIndex = (newIndex + 1) % 5; + SelectedIndex = (SelectedIndex + 1) % ItemsView.Count; + } + + if (offset >= threshold) + { + newIndex = (newIndex + 4) % 5; + SelectedIndex = (SelectedIndex + ItemsView.Count - 1) % ItemsView.Count; + } + + ArrangeItems(newIndex, oldSelectedIndex, true); + } + + base.OnPointerReleased(args); + } + + private void ArrangeItems(int newIndex, + int oldIndex = -1, + bool isAnimating = false) + { + if (compositor is not null + && container is not null + && items is not null + && indicatorVisual is not null) + { + double containerHeight = Bounds.Height; + double containerWidth = Bounds.Width; + container.Height = containerHeight; + + double targetSize = containerHeight; + + foreach (Border item in items) + { + if (item.Child is CarouselViewItem content) + { + content.Width = targetSize; + content.Height = targetSize; + } + + item.Width = targetSize; + item.Height = targetSize; + } + + double centreLeft = (containerWidth - targetSize) / 2; + double leftLeft = -targetSize + centreLeft; + double rightLeft = containerWidth - centreLeft; + + double[] offsets = + [ + leftLeft - targetSize + spacing * 1, + leftLeft + spacing * 2, + centreLeft + spacing * 3, + rightLeft + spacing * 4, + rightLeft + targetSize + spacing * 5 + ]; + + double centreOffset = spacing * (columnCount - 1) / 2 + spacing; + if (!isAnimating) + { + for (int i = 0; i < columnCount; i++) + { + itemVisuals[(newIndex + i - 2 + columnCount) % columnCount].Offset = + new Vector3((float)(offsets[i] - centreOffset), 0, 100); + } + + SetItems(); + } + else + { + int difference = newIndex - oldIndex; + finalOffset = difference switch + { + 0 => new Vector3D(0, 0, 0), + 1 => new Vector3D((float)(-targetSize - spacing), 0, 0), + -1 => new Vector3D((float)(targetSize + spacing), 0, 0), + _ => new Vector3D((float)(targetSize * Math.Sign(difference) + + spacing * Math.Sign(difference)), 0, 0) + }; + + indicatorAnimation = compositor.CreateVector3DKeyFrameAnimation(); + indicatorAnimation.InsertKeyFrame(1.0f, finalOffset); + indicatorAnimation.Duration = animationDuration; + indicatorAnimation.StopBehavior = AnimationStopBehavior.LeaveCurrentValue; + SetItems(); + + scopedBatch.Completed += () => + { + this.isAnimating = false; + for (int i = 0; i < columnCount; i++) + { + itemVisuals[(newIndex + i - 2 + columnCount) % columnCount].Offset = + new Vector3((float)(offsets[i] - centreOffset), 0, 0); + + } + }; + + indicatorVisual.StartAnimation("Offset", indicatorAnimation); + scopedBatch.Start(animationDuration); + + this.isAnimating = true; + } + } + } + private void OnCollectionChanged(object? sender, + NotifyCollectionChangedEventArgs args) => ArrangeItems(newIndex); + + private void PrepareAnimations() + { + animations.Clear(); + if (compositor is not null && indicatorVisual is not null && itemVisuals is not null) + { + for (int i = 0; i < itemVisuals.Count; i++) + { + ExpressionAnimation animation = compositor.CreateExpressionAnimation(); + + animation.Expression = $"Source.Offset + Vector3({itemVisuals[i].Offset.X}, 0, 0)"; + animation.SetReferenceParameter("Source", indicatorVisual); + animations.Add(animation); + } + } + } + + private void SetItems() + { + if (items is not null) + { + int itemCount = ItemsView.Count; + if (itemCount == 0) + { + return; + } + + int[] selectedIndexOffsets = new int[columnCount]; + int[] indexOffsets = new int[columnCount]; + + SelectedIndex = SelectedIndex < 0 ? 0 : SelectedIndex; + + for (int i = -2; i <= 2; i++) + { + selectedIndexOffsets[i + 2] = (SelectedIndex + i + itemCount) % itemCount; + indexOffsets[i + 2] = (newIndex + i + columnCount) % columnCount; + } + + for (int i = 0; i < columnCount; i++) + { + int index = selectedIndexOffsets[i]; + if (itemCount == 1) + { + index = 0; + } + + if (items[indexOffsets[i]] is Border border && border.Child is + CarouselViewItem content) + { + content.Content = ItemsView[index]; + content.SetSelected(indexOffsets.Length / 2 == i); + } + } + } + } +} \ No newline at end of file diff --git a/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselViewItem.cs b/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselViewItem.cs new file mode 100644 index 0000000..ed75a5f --- /dev/null +++ b/Toolkit.UI.Controls.Avalonia/CarouselView/CarouselViewItem.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace Toolkit.UI.Controls.Avalonia; + +public class CarouselViewItem : + ContentControl +{ + internal void SetSelected(bool selected) + { + PseudoClasses.Set(":selected", selected); + } +} diff --git a/Toolkit.UI.Controls.Avalonia/ContentDialog/ContentDialog.axaml b/Toolkit.UI.Controls.Avalonia/ContentDialog/ContentDialog.axaml new file mode 100644 index 0000000..b7cc4ce --- /dev/null +++ b/Toolkit.UI.Controls.Avalonia/ContentDialog/ContentDialog.axaml @@ -0,0 +1,357 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +