WPF animating a StackPanel's width from 0 to Auto?

12,071

Solution 1

Here is a quick mockup project I threw together.

In the Window's Loaded event, I simply set the stackpanel's visibility to Visible and it expands to fit its container width from left to right... Hopefully that's suits your needs.

Some things to note:

  • You must predefine the scale transform, else the animation will not play.
  • If you omit To in an animation, it will animate back to the default value.

And here is the code:

<Window x:Class="WpfApplication1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="600" Loaded="Window_Loaded">
    <Border HorizontalAlignment="Center" Width="300" Background="Gainsboro">
        <Border.Resources>
            <Style TargetType="StackPanel" x:Key="expand">
                <Setter Property="RenderTransform">
                    <Setter.Value>
                        <ScaleTransform ScaleX="1"/>
                    </Setter.Value>
                </Setter>
                <Style.Triggers>
                    <Trigger Property="Visibility" Value="Visible">
                        <Trigger.EnterActions>
                            <BeginStoryboard>
                                <Storyboard>
                                    <DoubleAnimation Storyboard.TargetProperty="RenderTransform.ScaleX"
                                                     From="0"
                                                     Duration="0:00:01"/>
                                </Storyboard>
                            </BeginStoryboard>
                        </Trigger.EnterActions>
                    </Trigger>
                </Style.Triggers>
            </Style>
        </Border.Resources>

        <StackPanel x:Name="stackpanel" Background="Gray" Visibility="Collapsed" Style="{StaticResource expand}"/>

    </Border>
</Window>

Solution 2

So, this is quite an old question, but I think it is a common enough scenario that you need to animate Width or Height from 0 to Auto (or similar) to justify an additional answer. I am not going to focus on the Alex's exact requirements here, so as to emphasize the general nature of my proposed solution.

Which is: writing your own Clipper control that would clip it's child's visible Width and Height to some fraction of them. Then we could animate those Fraction properties (0 -> 1) to achieve the desired effect. The code for Clipper is below, with all the helpers included.

public sealed class Clipper : Decorator
{
    public static readonly DependencyProperty WidthFractionProperty = DependencyProperty.RegisterAttached("WidthFraction", typeof(double), typeof(Clipper), new PropertyMetadata(1d, OnClippingInvalidated), IsFraction);
    public static readonly DependencyProperty HeightFractionProperty = DependencyProperty.RegisterAttached("HeightFraction", typeof(double), typeof(Clipper), new PropertyMetadata(1d, OnClippingInvalidated), IsFraction);
    public static readonly DependencyProperty BackgroundProperty = DependencyProperty.Register("Background", typeof(Brush), typeof(Clipper), new FrameworkPropertyMetadata(Brushes.Transparent, FrameworkPropertyMetadataOptions.AffectsRender));
    public static readonly DependencyProperty ConstraintProperty = DependencyProperty.Register("Constraint", typeof(ConstraintSource), typeof(Clipper), new PropertyMetadata(ConstraintSource.WidthAndHeight, OnClippingInvalidated), IsValidConstraintSource);

    private Size _childSize;
    private DependencyPropertySubscriber _childVerticalAlignmentSubcriber;
    private DependencyPropertySubscriber _childHorizontalAlignmentSubscriber;

    public Clipper()
    {
        ClipToBounds = true;
    }

    public Brush Background
    {
        get { return (Brush)GetValue(BackgroundProperty); }
        set { SetValue(BackgroundProperty, value); }
    }

    public ConstraintSource Constraint
    {
        get { return (ConstraintSource)GetValue(ConstraintProperty); }
        set { SetValue(ConstraintProperty, value); }
    }

    [AttachedPropertyBrowsableForChildren]
    public static double GetWidthFraction(DependencyObject obj)
    {
        return (double)obj.GetValue(WidthFractionProperty);
    }

    public static void SetWidthFraction(DependencyObject obj, double value)
    {
        obj.SetValue(WidthFractionProperty, value);
    }

    [AttachedPropertyBrowsableForChildren]
    public static double GetHeightFraction(DependencyObject obj)
    {
        return (double)obj.GetValue(HeightFractionProperty);
    }

    public static void SetHeightFraction(DependencyObject obj, double value)
    {
        obj.SetValue(HeightFractionProperty, value);
    }

    protected override Size MeasureOverride(Size constraint)
    {
        if (Child is null)
        {
            return Size.Empty;
        }

        switch (Constraint)
        {
            case ConstraintSource.WidthAndHeight:
                Child.Measure(constraint);
                break;

            case ConstraintSource.Width:
                Child.Measure(new Size(constraint.Width, double.PositiveInfinity));
                break;

            case ConstraintSource.Height:
                Child.Measure(new Size(double.PositiveInfinity, constraint.Height));
                break;

            case ConstraintSource.Nothing:
                Child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
                break;
        }

        var finalSize = Child.DesiredSize;
        if (Child is FrameworkElement childElement)
        {
            if (childElement.HorizontalAlignment == HorizontalAlignment.Stretch && constraint.Width > finalSize.Width && !double.IsInfinity(constraint.Width))
            {
                finalSize.Width = constraint.Width;
            }

            if (childElement.VerticalAlignment == VerticalAlignment.Stretch && constraint.Height > finalSize.Height && !double.IsInfinity(constraint.Height))
            {
                finalSize.Height = constraint.Height;
            }
        }

        _childSize = finalSize;

        finalSize.Width *= GetWidthFraction(Child);
        finalSize.Height *= GetHeightFraction(Child);

        return finalSize;
    }

    protected override Size ArrangeOverride(Size arrangeSize)
    {
        if (Child is null)
        {
            return Size.Empty;
        }

        var childSize = _childSize;
        var clipperSize = new Size(Math.Min(arrangeSize.Width, childSize.Width * GetWidthFraction(Child)),
                                   Math.Min(arrangeSize.Height, childSize.Height * GetHeightFraction(Child)));
        var offsetX = 0d;
        var offsetY = 0d;

        if (Child is FrameworkElement childElement)
        {
            if (childSize.Width > clipperSize.Width)
            {
                switch (childElement.HorizontalAlignment)
                {
                    case HorizontalAlignment.Right:
                        offsetX = -(childSize.Width - clipperSize.Width);
                        break;

                    case HorizontalAlignment.Center:
                        offsetX = -(childSize.Width - clipperSize.Width) / 2;
                        break;
                }
            }

            if (childSize.Height > clipperSize.Height)
            {
                switch (childElement.VerticalAlignment)
                {
                    case VerticalAlignment.Bottom:
                        offsetY = -(childSize.Height - clipperSize.Height);
                        break;

                    case VerticalAlignment.Center:
                        offsetY = -(childSize.Height - clipperSize.Height) / 2;
                        break;
                }
            }
        }

        Child.Arrange(new Rect(new Point(offsetX, offsetY), childSize));

        return clipperSize;
    }

    protected override void OnVisualChildrenChanged(DependencyObject visualAdded, DependencyObject visualRemoved)
    {
        void UpdateLayout(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (e.NewValue.Equals(HorizontalAlignment.Stretch) || e.NewValue.Equals(VerticalAlignment.Stretch))
            {
                InvalidateMeasure();
            }
            else
            {
                InvalidateArrange();
            }
        }

        _childHorizontalAlignmentSubscriber?.Unsubscribe();
        _childVerticalAlignmentSubcriber?.Unsubscribe();

        if (visualAdded is FrameworkElement childElement)
        {
            _childHorizontalAlignmentSubscriber = new DependencyPropertySubscriber(childElement, HorizontalAlignmentProperty, UpdateLayout);
            _childVerticalAlignmentSubcriber = new DependencyPropertySubscriber(childElement, VerticalAlignmentProperty, UpdateLayout);
        }
    }

    protected override void OnRender(DrawingContext drawingContext)
    {
        base.OnRender(drawingContext);
        drawingContext.DrawRectangle(Background, null, new Rect(RenderSize));
    }

    private static bool IsFraction(object value)
    {
        var numericValue = (double)value;
        return numericValue >= 0d && numericValue <= 1d;
    }

    private static void OnClippingInvalidated(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is UIElement element && VisualTreeHelper.GetParent(element) is Clipper translator)
        {
            translator.InvalidateMeasure();
        }
    }

    private static bool IsValidConstraintSource(object value)
    {
        return Enum.IsDefined(typeof(ConstraintSource), value);
    }
}

public enum ConstraintSource
{
    WidthAndHeight,
    Width,
    Height,
    Nothing
}

public class DependencyPropertySubscriber : DependencyObject
{    
    private static readonly DependencyProperty ValueProperty = DependencyProperty.Register("Value", typeof(object), typeof(DependencyPropertySubscriber), new PropertyMetadata(null, ValueChanged));

    private readonly PropertyChangedCallback _handler;

    public DependencyPropertySubscriber(DependencyObject dependencyObject, DependencyProperty dependencyProperty, PropertyChangedCallback handler)
    {
        if (dependencyObject is null)
        {
            throw new ArgumentNullException(nameof(dependencyObject));
        }

        if (dependencyProperty is null)
        {
            throw new ArgumentNullException(nameof(dependencyProperty));
        }

        _handler = handler ?? throw new ArgumentNullException(nameof(handler));

        var binding = new Binding() { Path = new PropertyPath(dependencyProperty), Source = dependencyObject, Mode = BindingMode.OneWay };
        BindingOperations.SetBinding(this, ValueProperty, binding);
    }

    public void Unsubscribe()
    {
        BindingOperations.ClearBinding(this, ValueProperty);
    }

    private static void ValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((DependencyPropertySubscriber)d)._handler(d, e);
    }
}

The usage is as follows:

<Clipper Constraint="WidthAndHeight">
    <Control Clipper.HeightFraction="0.5"
             Clipper.WidthFraction="0.5" />
</Clipper>

Note the Constraint property: it determines what the child control considers "Auto" dimensions. For example, if your control is static (has Height and Width set explicitly), you should set Constraint to Nothing to clip the fraction of the entire element. If your control is WrapPanel with Orientation set to Horizontal, Constraint should be set to Width, etc. If you are getting wrong clipping, try out out different constraints. Note also that Clipper respects you control's alignment, which can potentially be exploited in an animation (for example, while animating HeightFraction from 0 to 1, VerticalAlignment.Bottom will mean that the control "slides down", VerticalAlignment.Center - "opens up").

Share:
12,071
Alex Hope O'Connor
Author by

Alex Hope O'Connor

Software Engineer working for DSITIA in Brisbane Australia.

Updated on June 11, 2022

Comments

  • Alex Hope O'Connor
    Alex Hope O'Connor almost 2 years

    I am trying to animate a StackPanel when its visibility changed to grow from a width of 0 to its automatic width, here is what I have at the moment:

    <Trigger Property="Visibility" Value="Visible">
        <Setter Property="Width" Value="0"></Setter>
        <Trigger.EnterActions>
            <BeginStoryboard>
                <Storyboard>
                    <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="Width" Duration="0:0:1">
                        <DiscreteObjectKeyFrame KeyTime="0">
                            <DiscreteObjectKeyFrame.Value>
                                <System:Double>NaN</System:Double>
                            </DiscreteObjectKeyFrame.Value>
                        </DiscreteObjectKeyFrame>
                    </ObjectAnimationUsingKeyFrames>
                </Storyboard>
            </BeginStoryboard>
        </Trigger.EnterActions>
    </Trigger>
    

    Can someone explain how I might achieve this animation? Is it maybe not possible in the way I am trying to do it?

    Thanks, alex.

    • learningcs
      learningcs over 9 years
      I have produced some sample code by animating ScaleTransform from 0 to 1 as a double animation, however I don't seem to have control over which direction it animates from. Would you like me to post the sample code as an answer?
    • McGarnagle
      McGarnagle over 9 years
      @rshepp if you went to all that trouble, you may as well post even if it half-works.
    • Alex Hope O'Connor
      Alex Hope O'Connor over 9 years
      Yeah man, post it, the only other thing I found close to working was using a LayoutTransform but I didn't understand it well enough to make it suit my needs.
    • learningcs
      learningcs over 9 years
      It wasn't really any trouble, I quite often try to emulate and solve problems because I find it's the best way to learn
  • Alex Hope O'Connor
    Alex Hope O'Connor over 9 years
    Works well enough! Would rather animate the width directly but the scaling animation does not look to shabby either. Thanks!
  • Scott Baker
    Scott Baker almost 9 years
    This works very well for animating when Visibility is changed to "Visible" - how would I add a "reverse" animation for when Visibility is "Collapsed" ?
  • Scott Baker
    Scott Baker almost 9 years
    I saw in your comment above that you couldn't control the direction of the animation; all you have to do is set the RenderTransformOrigin to 1,0 to animate from the other side or 0.5,0 to animate from the center.
  • learningcs
    learningcs almost 9 years
    @ScottSEA Good info regarding the origin, the less-experienced me didn't consider it at the time :P Using the visibility property probably isn't the best way to trigger this animation if you intend it to reverse. Collapsing the control hides it immediately, so you won't see any animation -- perhaps you could use the "Tag" property as the trigger for both directions. For example, setting the tag to 1 will immediately set the controls to visible and then play the animation. Then, setting the tag to 0 will play the reverse animation and then collapse the control after the animation has finished.
  • Admin
    Admin over 2 years
    Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.
  • Duncan Groenewald
    Duncan Groenewald about 2 years
    This is cool but how could I rather slide it in from the left and slide it out again without the width compression/decompression effect ?