Nested Scroll Areas

2020-01-31 02:48发布

问题:

I creating a control for WPF, and I have a question for you WPF gurus out there.

I want my control to be able to expand to fit a resizable window.

In my control, I have a list box that I want to expand with the window. I also have other controls around the list box (buttons, text, etc).

I want to be able to set a minimum size on my control, but I want the window to be able to be sized smaller by creating scroll bars for viewing the control.

This creates nested scroll areas: One for the list box and a ScrollViewer wrapping the whole control.

Now, if the list box is set to auto size, it will never have a scroll bar because it is always drawn full size within the ScrollViewer.

I only want the control to scroll if the content can't get any smaller, otherwise I don't want to scroll the control; instead I want to scroll the list box inside the control.

How can I alter the default behavior of the ScrollViewer class? I tried inheriting from the ScrollViewer class and overriding the MeasureOverride and ArrangeOverride classes, but I couldn't figure out how to measure and arrange the child properly. It appears that the arrange has to affect the ScrollContentPresenter somehow, not the actual content child.

Any help/suggestions would be much appreciated.

回答1:

I've created a class to work around this problem:

public class RestrictDesiredSize : Decorator
{
    Size lastArrangeSize = new Size(double.PositiveInfinity, double.PositiveInfinity);

    protected override Size MeasureOverride(Size constraint)
    {
        Debug.WriteLine("Measure: " + constraint);
        base.MeasureOverride(new Size(Math.Min(lastArrangeSize.Width, constraint.Width),
                                      Math.Min(lastArrangeSize.Height, constraint.Height)));
        return new Size(0, 0);
    }

    protected override Size ArrangeOverride(Size arrangeSize)
    {
        Debug.WriteLine("Arrange: " + arrangeSize);
        if (lastArrangeSize != arrangeSize) {
            lastArrangeSize = arrangeSize;
            base.MeasureOverride(arrangeSize);
        }
        return base.ArrangeOverride(arrangeSize);
    }
}

It will always return a desired size of (0,0), even if the containing element wants to be bigger. Usage:

<local:RestrictDesiredSize MinWidth="200" MinHeight="200">
     <ListBox />
</local>


回答2:

You problem arises, because Controls within a ScrollViewer have virtually unlimited space available. Therefore your inner ListBox thinks it can avoid scrolling by taking up the complete height necessary to display all its elements. Of course in your case that behaviour has the unwanted side effect of exercising the outer ScrollViewer too much.

The objective therefore is to get the ListBox to use the visible height within the ScrollViewer iff there is enough of it and a certain minimal height otherwise. To achieve this, the most direct way is to inherit from ScrollViewer and override MeasureOverride() to pass an appropriately sized availableSize (that is the given availableSize blown up to the minimal size instead of the "usual" infinity) to the Visuals found by using VisualChildrenCount and GetVisualChild(int).



回答3:

I used Daniels solution. That works great. Thank you.

Then I added two boolean dependency properties to the decorator class: KeepWidth and KeepHeight. So the new feature can be suppressed for one dimension.

This requires a change in MeasureOverride:

protected override Size MeasureOverride(Size constraint)
{
    var innerWidth = Math.Min(this._lastArrangeSize.Width, constraint.Width);
    var innerHeight = Math.Min(this._lastArrangeSize.Height, constraint.Height);
    base.MeasureOverride(new Size(innerWidth, innerHeight));

    var outerWidth = KeepWidth ? Child.DesiredSize.Width : 0;
    var outerHeight = KeepHeight ? Child.DesiredSize.Height : 0;
    return new Size(outerWidth, outerHeight);
}


回答4:

While I wouldn't recommend creating a UI that requires outer scroll bars you can accomplish this pretty easily:

<Window
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  >    
    <ScrollViewer HorizontalScrollBarVisibility="Auto" 
                  VerticalScrollBarVisibility="Auto">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <ListBox Grid.Row="0" Grid.RowSpan="3" Grid.Column="0" MinWidth="200"/>
            <Button Grid.Row="0" Grid.Column="1" Content="Button1"/>
            <Button Grid.Row="1" Grid.Column="1" Content="Button2"/>
            <Button Grid.Row="2" Grid.Column="1" Content="Button3"/>
        </Grid>
    </ScrollViewer>
</Window>

I don't really recommend this. WPF provides exceptional layout systems, like Grid, and you should try to allow the app to resize itself as needed. Perhaps you can set a MinWidth/MinHeight on the window itself to prevent this resizing?



回答5:

Create a method in the code-behind that sets the ListBox's MaxHeight to the height of whatever control is containing it and other controls. If the Listbox has any controls/margins/padding above or below it, subtract their heights from the container height assigned to MaxHeight. Call this method in the main windows "loaded" and "window resize" event handlers.

This should give you the best of both worlds. You are giving the ListBox a "fixed" size that will cause it to scroll in spite of the fact that the main window has its own scrollbar.



回答6:

for 2 ScrollViewer

   public class ScrollExt: ScrollViewer
{
    Size lastArrangeSize = new Size(double.PositiveInfinity, double.PositiveInfinity);

    public ScrollExt()
    {

    }
    protected override Size MeasureOverride(Size constraint)
    {
        base.MeasureOverride(new Size(Math.Min(lastArrangeSize.Width, constraint.Width),
                                      Math.Min(lastArrangeSize.Height, constraint.Height)));
        return new Size(0, 0);
    }

    protected override Size ArrangeOverride(Size arrangeSize)
    {
        if (lastArrangeSize != arrangeSize)
        {
            lastArrangeSize = arrangeSize;
            base.MeasureOverride(arrangeSize);
        }
        return base.ArrangeOverride(arrangeSize);
    }
}

code:

  <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
        <Grid >
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <TextBlock Background="Beige" Width="600" Text="Example"/>
            <Grid Grid.Column="1" x:Name="grid">
                    <Grid Grid.Column="1" Margin="25" Background="Green">
                    <local:ScrollExt HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
                        <Grid Width="10000" Margin="25" Background="Red" />
                    </local:ScrollExt>
                    </Grid>
                </Grid>
        </Grid>
    </ScrollViewer>


回答7:

I ended up combining Daniels answer and Heiner's answer. I decided to post the entire solution to make it easier for people to adopt this if needed. Here's my decorator class:

public class RestrictDesiredSizeDecorator : Decorator
{
    public static readonly DependencyProperty KeepWidth;
    public static readonly DependencyProperty KeepHeight;

    #region Dependency property setters and getters
    public static void SetKeepWidth(UIElement element, bool value)
    {
        element.SetValue(KeepWidth, value);
    }

    public static bool GetKeepWidth(UIElement element)
    {
        return (bool)element.GetValue(KeepWidth);
    }

    public static void SetKeepHeight(UIElement element, bool value)
    {
        element.SetValue(KeepHeight, value);
    }

    public static bool GetKeepHeight(UIElement element)
    {
        return (bool)element.GetValue(KeepHeight);
    }
    #endregion

    private Size _lastArrangeSize = new Size(double.PositiveInfinity, double.PositiveInfinity);

    static RestrictDesiredSizeDecorator()
    {
        KeepWidth = DependencyProperty.RegisterAttached(
            nameof(KeepWidth),
            typeof(bool),
            typeof(RestrictDesiredSizeDecorator));

        KeepHeight = DependencyProperty.RegisterAttached(
            nameof(KeepHeight),
            typeof(bool),
            typeof(RestrictDesiredSizeDecorator));
    }

    protected override Size MeasureOverride(Size constraint)
    {
        Debug.WriteLine("Measure: " + constraint);

        var keepWidth = GetValue(KeepWidth) as bool? ?? false;
        var keepHeight = GetValue(KeepHeight) as bool? ?? false;

        var innerWidth = keepWidth ? constraint.Width : Math.Min(this._lastArrangeSize.Width, constraint.Width);
        var innerHeight = keepHeight ? constraint.Height : Math.Min(this._lastArrangeSize.Height, constraint.Height);
        base.MeasureOverride(new Size(innerWidth, innerHeight));

        var outerWidth = keepWidth ? Child.DesiredSize.Width : 0;
        var outerHeight = keepHeight ? Child.DesiredSize.Height : 0;

        return new Size(outerWidth, outerHeight);
    }

    protected override Size ArrangeOverride(Size arrangeSize)
    {
        Debug.WriteLine("Arrange: " + arrangeSize);

        if (_lastArrangeSize != arrangeSize)
        {
            _lastArrangeSize = arrangeSize;
            base.MeasureOverride(arrangeSize);
        }

        return base.ArrangeOverride(arrangeSize);
    }
}

and here's how I use it in the xaml:

<ScrollViewer>
    <StackPanel Orientation="Vertical">
        <Whatever />

        <decorators:RestrictDesiredSizeDecorator MinWidth="100" KeepHeight="True">
            <TextBox
                Text="{Binding Comment, UpdateSourceTrigger=PropertyChanged}"
                Height="Auto"
                MaxHeight="360"
                VerticalScrollBarVisibility="Auto"
                HorizontalScrollBarVisibility="Auto"
                AcceptsReturn="True"
                AcceptsTab="True"
                TextWrapping="WrapWithOverflow"
                />
        </decorators:RestrictDesiredSizeDecorator>

        <Whatever />
    </StackPanel>
</ScrollViewer

The above creates a textbox that will grow vertically (until it hits MaxHeight) but will match the parent's width without growing the outer ScrollViewer. Resizing the window/ScrollViewer to less than 100 wide will force the outer ScrollViewer to show the horizontal scroll bars. Other controls with inner ScrollViewers can be used as well, including complex grids.