mandarin
mandarin

Reputation: 1386

Shrink ItemsControl items when visible space is filled

I want to create a data-binded horizontal layout ItemsControl where for each item there would be a Button. When I add new items to the collection the ItemsControl should grow, relative to the Window it is in, until it reaches it's MaxWidth property. Then all buttons should shrink equally to fit inside MaxWidth. Something similar to the tabs of a Chrome browser.

Tabs with space: enter image description here

Tabs with no empty space: enter image description here

So far I've gotten to this:

    <ItemsControl Name="ButtonsControl" MaxWidth="400">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <WrapPanel />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate DataType="{x:Type dataclasses:TextNote}">
                <Button Content="{Binding Title}" MinWidth="80"/>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>

When adding items the expansion of the StackPanel and Window are fine, but when MaxWidth is reached the items just start to disappear.

Upvotes: 4

Views: 197

Answers (2)

vesan
vesan

Reputation: 3369

You can achieve something like this using a UniformGrid with Rows="1". The problem is that you can either have it stretched or not and neither of these options will do exactly what you want:

  • If it's stretched, then your "tabs" will always fill the whole available width. So, if you only have 1, it will be stretched across the whole width. If you set MaxWidth for the "tab", then if you have 2 they will not be adjacent but floating each in the middle of its column.
  • If it's left-aligned, then it will be difficult to get any padding/margin in your control, because when it shrinks, the padding will stay, making the actual content invisible.

So basically you need a control that has a "preferred" width:

  • When it has more space available than this preferred width, it sets itself to the preferred width.
  • When it has less space, it just takes up all the space it has.

This cannot be achieved using XAML (as far as I can tell), but it's not too difficult to do in code-behind. Let's create a custom control for the "tab" (namespaces omitted):

<ContentControl x:Class="WpfApplication1.UserControl1">
    <ContentControl.Template>
        <ControlTemplate TargetType="ContentControl">
            <Border BorderBrush="Black" BorderThickness="1" Padding="0,5">
                <ContentPresenter HorizontalAlignment="Center" Content="{TemplateBinding Content}"></ContentPresenter>
            </Border>
        </ControlTemplate>
    </ContentControl.Template>

Code behind:

public partial class UserControl1 : ContentControl
{
    public double DefaultWidth
    {
        get { return (double)GetValue(DefaultWidthProperty); }
        set { SetValue(DefaultWidthProperty, value); }
    }
    public static readonly DependencyProperty DefaultWidthProperty =
        DependencyProperty.Register("DefaultWidth", typeof(double), typeof(UserControl1), new PropertyMetadata(200.0));

    public UserControl1()
    {
        InitializeComponent();
    }

    protected override Size MeasureOverride(Size constraint)
    {
        Size baseSize = base.MeasureOverride(constraint);
        baseSize.Width = Math.Min(DefaultWidth, constraint.Width);
        return baseSize;
    }

    protected override Size ArrangeOverride(Size arrangeBounds)
    {
        Size baseBounds = base.ArrangeOverride(arrangeBounds);
        baseBounds.Width = Math.Min(DefaultWidth, arrangeBounds.Width);
        return baseBounds;
    }
}

Then, you can create your ItemsControl, using a UniformGrid as the container:

<ItemsControl ItemsSource="{Binding Items}">
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <local:UserControl1 Content="{Binding}" Margin="0,0,5,0" DefaultWidth="150"></local:UserControl1>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <UniformGrid Rows="1" HorizontalAlignment="Left"></UniformGrid>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

Here's a screenshot of the result with 3 items and many items (don't feel like counting them :)

Tabs

Upvotes: 0

Glen Thomas
Glen Thomas

Reputation: 10754

I don't think it is possible to produce that behaviour using any combination of the standard WPF controls, but this custom StackPanel control should do the job:

public class SqueezeStackPanel : Panel
{
    private const double Tolerance = 0.001;

    public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register
        ("Orientation", typeof (Orientation), typeof (SqueezeStackPanel),
            new FrameworkPropertyMetadata(Orientation.Horizontal, FrameworkPropertyMetadataOptions.AffectsMeasure,
                OnOrientationChanged));

    private readonly Dictionary<UIElement, Size> _childToConstraint = new Dictionary<UIElement, Size>();
    private bool _isMeasureDirty;
    private bool _isHorizontal = true;
    private List<UIElement> _orderedSequence;
    private Child[] _children;

    static SqueezeStackPanel()
    {
        DefaultStyleKeyProperty.OverrideMetadata
            (typeof (SqueezeStackPanel),
                new FrameworkPropertyMetadata(typeof (SqueezeStackPanel)));
    }

    protected override bool HasLogicalOrientation
    {
        get { return true; }
    }

    protected override Orientation LogicalOrientation
    {
        get { return Orientation; }
    }

    public Orientation Orientation
    {
        get { return (Orientation) GetValue(OrientationProperty); }
        set { SetValue(OrientationProperty, value); }
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        var size = new Size(_isHorizontal ? 0 : finalSize.Width, !_isHorizontal ? 0 : finalSize.Height);

        var childrenCount = Children.Count;

        var rc = new Rect();
        for (var index = 0; index < childrenCount; index++)
        {
            var child = _orderedSequence[index];

            var childVal = _children[index].Val;
            if (_isHorizontal)
            {
                rc.Width = double.IsInfinity(childVal) ? child.DesiredSize.Width : childVal;
                rc.Height = Math.Max(finalSize.Height, child.DesiredSize.Height);
                size.Width += rc.Width;
                size.Height = Math.Max(size.Height, rc.Height);
                child.Arrange(rc);
                rc.X += rc.Width;
            }
            else
            {
                rc.Width = Math.Max(finalSize.Width, child.DesiredSize.Width);
                rc.Height = double.IsInfinity(childVal) ? child.DesiredSize.Height : childVal;
                size.Width = Math.Max(size.Width, rc.Width);
                size.Height += rc.Height;
                child.Arrange(rc);
                rc.Y += rc.Height;
            }
        }

        return new Size(Math.Max(finalSize.Width, size.Width), Math.Max(finalSize.Height, size.Height));
    }

    protected override Size MeasureOverride(Size availableSize)
    {
        for (var i = 0; i < 3; i++)
        {
            _isMeasureDirty = false;

            var childrenDesiredSize = new Size();

            var childrenCount = Children.Count;

            if (childrenCount == 0)
                return childrenDesiredSize;

            var childConstraint = GetChildrenConstraint(availableSize);

            _children = new Child[childrenCount];

            _orderedSequence = Children.Cast<UIElement>().ToList();

            for (var index = 0; index < childrenCount; index++)
            {
                if (_isMeasureDirty)
                    break;

                var child = _orderedSequence[index];

                const double minLength = 0.0;
                const double maxLength = double.PositiveInfinity;

                MeasureChild(child, childConstraint);

                if (_isHorizontal)
                {
                    childrenDesiredSize.Width += child.DesiredSize.Width;
                    _children[index] = new Child(minLength, maxLength, child.DesiredSize.Width);
                    childrenDesiredSize.Height = Math.Max(childrenDesiredSize.Height, child.DesiredSize.Height);
                }
                else
                {
                    childrenDesiredSize.Height += child.DesiredSize.Height;
                    _children[index] = new Child(minLength, maxLength, child.DesiredSize.Height);
                    childrenDesiredSize.Width = Math.Max(childrenDesiredSize.Width, child.DesiredSize.Width);
                }
            }

            if (_isMeasureDirty)
                continue;

            var current = _children.Sum(s => s.Val);
            var target = GetSizePart(availableSize);

            var finalSize = new Size
                (Math.Min(availableSize.Width, _isHorizontal ? current : childrenDesiredSize.Width),
                    Math.Min(availableSize.Height, _isHorizontal ? childrenDesiredSize.Height : current));

            if (double.IsInfinity(target))
                return finalSize;

            RecalcChilds(current, target);

            current = 0.0;
            for (var index = 0; index < childrenCount; index++)
            {
                var child = _children[index];

                if (IsGreater(current + child.Val, target, Tolerance) &&
                    IsGreater(target, current, Tolerance))
                {
                    var rest = IsGreater(target, current, Tolerance) ? target - current : 0.0;
                    if (IsGreater(rest, child.Min, Tolerance))
                        child.Val = rest;
                }

                current += child.Val;
            }

            RemeasureChildren(finalSize);

            finalSize = new Size
                (Math.Min(availableSize.Width, _isHorizontal ? target : childrenDesiredSize.Width),
                    Math.Min(availableSize.Height, _isHorizontal ? childrenDesiredSize.Height : target));

            if (_isMeasureDirty)
                continue;

            return finalSize;
        }

        return new Size();
    }

    public static double GetHeight(Thickness thickness)
    {
        return thickness.Top + thickness.Bottom;
    }

    public static double GetWidth(Thickness thickness)
    {
        return thickness.Left + thickness.Right;
    }

    protected override void OnVisualChildrenChanged(DependencyObject visualAdded, DependencyObject visualRemoved)
    {
        base.OnVisualChildrenChanged(visualAdded, visualRemoved);

        var removedUiElement = visualRemoved as UIElement;

        if (removedUiElement != null)
            _childToConstraint.Remove(removedUiElement);
    }

    private Size GetChildrenConstraint(Size availableSize)
    {
        return new Size
            (_isHorizontal ? double.PositiveInfinity : availableSize.Width,
                !_isHorizontal ? double.PositiveInfinity : availableSize.Height);
    }

    private double GetSizePart(Size size)
    {
        return _isHorizontal ? size.Width : size.Height;
    }

    private static bool IsGreater(double a, double b, double tolerance)
    {
        return a - b > tolerance;
    }

    private void MeasureChild(UIElement child, Size childConstraint)
    {
        Size lastConstraint;
        if ((child.IsMeasureValid && _childToConstraint.TryGetValue(child, out lastConstraint) &&
                lastConstraint.Equals(childConstraint))) return;

        child.Measure(childConstraint);
        _childToConstraint[child] = childConstraint;
    }

    private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var panel = (SqueezeStackPanel) d;
        panel._isHorizontal = panel.Orientation == Orientation.Horizontal;
    }

    private void RecalcChilds(double current, double target)
    {
        var shouldShrink = IsGreater(current, target, Tolerance);

        if (shouldShrink)
            ShrinkChildren(_children, target);
    }

    private void RemeasureChildren(Size availableSize)
    {
        var childrenCount = Children.Count;
        if (childrenCount == 0)
            return;

        var childConstraint = GetChildrenConstraint(availableSize);
        for (var index = 0; index < childrenCount; index++)
        {
            var child = _orderedSequence[index];
            if (Math.Abs(GetSizePart(child.DesiredSize) - _children[index].Val) > Tolerance)
                MeasureChild(child, new Size(_isHorizontal ? _children[index].Val : childConstraint.Width,
                    !_isHorizontal ? _children[index].Val : childConstraint.Height));
        }
    }

    private static void ShrinkChildren(IEnumerable<Child> children, double target)
    {
        var sortedChilds = children.OrderBy(v => v.Val).ToList();
        var minValidTarget = sortedChilds.Sum(s => s.Min);
        if (minValidTarget > target)
        {
            foreach (var child in sortedChilds)
                child.Val = child.Min;
            return;
        }
        do
        {
            var tmpTarget = target;
            for (var iChild = 0; iChild < sortedChilds.Count; iChild++)
            {
                var child = sortedChilds[iChild];
                if (child.Val*(sortedChilds.Count - iChild) >= tmpTarget)
                {
                    var avg = tmpTarget/(sortedChilds.Count - iChild);
                    var success = true;
                    for (var jChild = iChild; jChild < sortedChilds.Count; jChild++)
                    {
                        var tChild = sortedChilds[jChild];
                        tChild.Val = Math.Max(tChild.Min, avg);

                        // Min constraint skip success expand on this iteration
                        if (Math.Abs(avg - tChild.Val) <= Tolerance) continue;

                        target -= tChild.Val;
                        success = false;
                        sortedChilds.RemoveAt(jChild);
                        jChild--;
                    }
                    if (success)
                        return;

                    break;
                }
                tmpTarget -= child.Val;
            }
        } while (sortedChilds.Count > 0);
    }

    private class Child
    {
        public readonly double Min;
        public double Val;

        public Child(double min, double max, double val)
        {
            Min = min;
            Val = val;

            Val = Math.Max(min, val);
            Val = Math.Min(max, Val);
        }
    }
}

Try using it as your ItemsPanelTemplate:

<ItemsControl Name="ButtonsControl" MaxWidth="400">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <local:SqueezeStackPanel />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate DataType="{x:Type dataclasses:TextNote}">
            <Button Content="{Binding Title}" MinWidth="80"/>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

I can't be sure based on the code that you have supplied, but I think you will have better layout results by removing your MaxWidth on the ItemsControl.

Upvotes: 2

Related Questions