feat(ui): folders slide open and closed, rows below follow

This commit is contained in:
mika kuns
2026-09-30 10:25:17 +02:00
parent 928e3905d9
commit 2be8ba5d66
5 changed files with 108 additions and 23 deletions
-13
View File
@@ -867,19 +867,6 @@
<Setter Property="BorderBrush" Value="Transparent" /> <Setter Property="BorderBrush" Value="Transparent" />
</Style> </Style>
<!-- Folder tree rows sit tighter than the smart lists — a deep tree gets long fast. --> <!-- Folder tree rows sit tighter than the smart lists — a deep tree gets long fast. -->
<!-- Folder expand: children drop down from under the header. Collapse stays instant. -->
<Style Selector="Panel.folder-children">
<Setter Property="RenderTransformOrigin" Value="50%,0%" />
<Setter Property="RenderTransform" Value="scaleY(1)" />
</Style>
<Style Selector="Panel.folder-children.animate[IsVisible=True]">
<Style.Animations>
<Animation Duration="0:0:0.16" Easing="CubicEaseOut" FillMode="Forward">
<KeyFrame Cue="0%"> <Setter Property="ScaleTransform.ScaleY" Value="0.4"/><Setter Property="Opacity" Value="0"/></KeyFrame>
<KeyFrame Cue="100%"><Setter Property="ScaleTransform.ScaleY" Value="1"/><Setter Property="Opacity" Value="1"/></KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Border.list-item.tree-row"> <Style Selector="Border.list-item.tree-row">
<Setter Property="Padding" Value="4,4,10,4" /> <Setter Property="Padding" Value="4,4,10,4" />
</Style> </Style>
@@ -18,8 +18,6 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
[ObservableProperty] private string _iconKey = "Folder"; [ObservableProperty] private string _iconKey = "Folder";
/// <summary>Hex colour for name + guide line; null = theme default.</summary> /// <summary>Hex colour for name + guide line; null = theme default.</summary>
[ObservableProperty] private string? _color; [ObservableProperty] private string? _color;
/// <summary>Set on the first user toggle so the expand animation skips folders that load already open.</summary>
[ObservableProperty] private bool _animateExpand;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowCount))] [NotifyPropertyChangedFor(nameof(ShowCount))]
private bool _isCollapsed; private bool _isCollapsed;
@@ -730,7 +730,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
private void ToggleFolder(ListFolderNavItemViewModel? folder) private void ToggleFolder(ListFolderNavItemViewModel? folder)
{ {
if (folder is null) return; if (folder is null) return;
folder.AnimateExpand = true;
folder.IsCollapsed = !folder.IsCollapsed; folder.IsCollapsed = !folder.IsCollapsed;
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel(); if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -0,0 +1,99 @@
using System;
using System.Threading;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Styling;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Accordion host: animates its height between 0 and the child's natural height, so content
/// below slides instead of jumping. The first value (from the binding) applies without animation.
/// </summary>
public sealed class ExpandPanel : Decorator
{
private static readonly TimeSpan Duration = TimeSpan.FromMilliseconds(180);
public static readonly StyledProperty<bool> IsExpandedProperty =
AvaloniaProperty.Register<ExpandPanel, bool>(nameof(IsExpanded));
/// <summary>0 = collapsed, 1 = fully open; drives the measured height.</summary>
public static readonly StyledProperty<double> ProgressProperty =
AvaloniaProperty.Register<ExpandPanel, double>(nameof(Progress));
private CancellationTokenSource? _cts;
private bool _arranged;
static ExpandPanel()
{
AffectsMeasure<ExpandPanel>(ProgressProperty);
ClipToBoundsProperty.OverrideDefaultValue<ExpandPanel>(true);
}
/// <summary>Whether the content is shown.</summary>
public bool IsExpanded
{
get => GetValue(IsExpandedProperty);
set => SetValue(IsExpandedProperty, value);
}
/// <summary>Current open fraction, animated between 0 and 1.</summary>
public double Progress
{
get => GetValue(ProgressProperty);
set => SetValue(ProgressProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == IsExpandedProperty)
Animate(IsExpanded ? 1 : 0);
else if (change.Property == ProgressProperty && Child is { } child)
child.IsVisible = Progress > 0;
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (Child is { } child) child.IsVisible = Progress > 0;
}
private void Animate(double to)
{
_cts?.Cancel();
var from = Progress;
Progress = to;
if (!_arranged || from == to) return;
_cts = new CancellationTokenSource();
var animation = new Animation
{
Duration = Duration,
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(ProgressProperty, from) } },
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(ProgressProperty, to) } },
},
};
_ = animation.RunAsync(this, _cts.Token);
}
protected override Size MeasureOverride(Size availableSize)
{
if (Child is not { } child) return default;
child.Measure(availableSize.WithHeight(double.PositiveInfinity));
return new Size(child.DesiredSize.Width, child.DesiredSize.Height * Progress);
}
protected override Size ArrangeOverride(Size finalSize)
{
_arranged = true;
// Full natural height, top-aligned; ClipToBounds cuts off what isn't revealed yet.
Child?.Arrange(new Rect(0, 0, finalSize.Width, Child.DesiredSize.Height));
return finalSize;
}
}
@@ -3,6 +3,7 @@
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:converters="using:ClaudeDo.Ui.Converters" xmlns:converters="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView" x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel"> x:DataType="vm:ListsIslandViewModel">
<!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over <!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over
@@ -162,13 +163,14 @@
<!-- Guide line drops from under the chevron's centre (tree-row padding 4 + half the 9px <!-- Guide line drops from under the chevron's centre (tree-row padding 4 + half the 9px
glyph); children start past it so their highlight never touches the line. --> glyph); children start past it so their highlight never touches the line. -->
<Panel Classes="folder-children" Classes.animate="{Binding AnimateExpand}" <ctl:ExpandPanel IsExpanded="{Binding !IsCollapsed}">
IsVisible="{Binding !IsCollapsed}"> <Panel>
<Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4" <Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4"
Background="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=LineBrush}" Background="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=LineBrush}"
IsHitTestVisible="False"/> IsHitTestVisible="False"/>
<ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/> <ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/>
</Panel> </Panel>
</ctl:ExpandPanel>
</StackPanel> </StackPanel>
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0" <Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"