[WPF 自定義控件]了解WPF的布局過程,並利用Measure為Expander添加動畫


1. 前言

這篇文章介紹WPF UI元素的兩步布局過程,並且通過Resizer控件介紹只使用Measure可以實現些什么內容。

我不建議初學者做太多動畫的工作,但合適的動畫可以引導用戶視線,提升用戶體驗。例如上圖的這種動畫,這種動畫挺常見的,在內容的高度改變時動態地改變自身的高度,除了好看以外,對用戶體驗也很有改善。可惜的是WPF本身沒有默認這種這方面的支持,連Expander的展開/折疊都沒有動畫。為此我實現了一個可以在內容大小改變時以動畫的方式改變自身大小的Resizer控件(想不到有什么好的命名,請求建議)。其實老老實實從Silverlight Toolkit移植AccordionItem就好,但我想通過這個控件介紹一些布局(及動畫)的概念。Resizer使用方式如下XAML所示:

<StackPanel>
    <kino:KinoResizer HorizontalContentAlignment="Stretch">
        <Expander Header="Expander1">
            <Rectangle Height="100"
                       Fill="Red" />
        </Expander>
    </kino:KinoResizer>
    <kino:KinoResizer HorizontalContentAlignment="Stretch">
        <Expander Header="Expander2">
            <Rectangle Height="100"
                       Fill="Blue" />
        </Expander>
    </kino:KinoResizer>
</StackPanel>

2. 需要了解的概念

為了實現這個控件首先要了解WPF UI元素的布局過程。

2.1 兩步布局過程

WPF的布局大致上分為Measure和Arrange兩步,布局元素首先遞歸地用Measure計算所有子元素所需的大小,然后使用Arrange實現布局。

以StackPanel為例,當StackPanel需要布局的時候,它首先會得知有多少空間可用,然后用這個可用空間詢問Children的所有子元素它們需要多大空間,這是Measure;得知所有子元素需要的空間后,結合自身的布局邏輯將子元素確定實際尺寸及安放的位置,這是Arrange。

當StackPanel需要重新布局(如StackPanel的大小改變),這時候StackPanel就重復兩步布局過程。如果StackPanel的某個子元素需要重新布局,它也會通知StackPanel需要重新布局。

2.2 MeasureOverride

MeasureOverride在派生類中重寫,用於測量子元素在布局中所需的大小。簡單來說就是父元素告訴自己有多少空間可用,自己再和自己的子元素商量后,把自己需要的尺寸告訴父元素。

2.3 DesiredSize

DesiredSize指經過Measure后確定的期待尺寸。下面這段代碼演示了如何使用MeasureOverride和DesiredSize:

protected override Size MeasureOverride(Size availableSize)
{
    Size panelDesiredSize = new Size();

    // In our example, we just have one child. 
    // Report that our panel requires just the size of its only child.
    foreach (UIElement child in InternalChildren)
    {
        child.Measure(availableSize);
        panelDesiredSize = child.DesiredSize;
    }

    return panelDesiredSize ;
}

2.4 InvalidateMeasure

InvalidateMeasure使元素當前的布局測量無效,並且異步地觸發重新測量。

2.5 IsMeasureValid

IsMeasureValid指示布局測量返回的當前大小是否有效,可以使用InvalidateMeasure使這個值變為False。

3. 實現

Resizer不需要用到Arrange,所以了解上面這些概念就夠了。Resizer的原理很簡單,Reszier的ControlTemplate中包含一個ContentControl(InnerContentControl),當這個InnerContentControl的大小改變時請求Resizer重新布局,Resizer啟動一個Storyboard,以InnerContentControl.DesiredSize為最終值逐漸改變Resizer的ContentHeight和ContentWidth屬性:

DoubleAnimation heightAnimation;
DoubleAnimation widthAnimation;
if (Animation != null)
{
    heightAnimation = Animation.Clone();
    Storyboard.SetTarget(heightAnimation, this);
    Storyboard.SetTargetProperty(heightAnimation, new PropertyPath(ContentHeightProperty));

    widthAnimation = Animation.Clone();
    Storyboard.SetTarget(widthAnimation, this);
    Storyboard.SetTargetProperty(widthAnimation, new PropertyPath(ContentWidthProperty));
}
else
{
    heightAnimation = _defaultHeightAnimation;
    widthAnimation = _defaultWidthAnimation;
}

heightAnimation.From = ActualHeight;
heightAnimation.To = InnerContentControl.DesiredSize.Height;
widthAnimation.From = ActualWidth;
widthAnimation.To = InnerContentControl.DesiredSize.Width;

_resizingStoryboard.Children.Clear();
_resizingStoryboard.Children.Add(heightAnimation);
_resizingStoryboard.Children.Add(widthAnimation);

ContentWidth和ContentHeight改變時調用InvalidateMeasure()請求重新布局,MeasureOverride返回ContentHeight和ContentWidth的值。這樣Resizer的大小就根據Storyboard的進度逐漸改變,實現了動畫效果。

protected override Size MeasureOverride(Size constraint)
{
    if (_isResizing)
        return new Size(ContentWidth, ContentHeight);

    if (_isInnerContentMeasuring)
    {
        _isInnerContentMeasuring = false;
        ChangeSize(true);
    }

    return base.MeasureOverride(constraint);
}

private void ChangeSize(bool useAnimation)
{
    if (InnerContentControl == null)
    {
        return;
    }

    if (useAnimation == false)
    {
        ContentHeight = InnerContentControl.ActualHeight;
        ContentWidth = InnerContentControl.ActualWidth;
    }
    else
    {
        if (_isResizing)
        {
            ResizingStoryboard.Stop();
        }

        _isResizing = true;
        ResizingStoryboard.Begin();
    }
}

用Resizer控件可以簡單地為Expander添加動畫,效果如下:

最后,Resizer還提供DoubleAnimation Animation屬性用於修改動畫,用法如下:

<kino:KinoResizer HorizontalContentAlignment="Stretch">
    <kino:KinoResizer.Animation>
        <DoubleAnimation BeginTime="0:0:0"
                         Duration="0:0:3">
            <DoubleAnimation.EasingFunction>
                <QuinticEase EasingMode="EaseOut" />
            </DoubleAnimation.EasingFunction>
        </DoubleAnimation>
    </kino:KinoResizer.Animation>
    <TextBox AcceptsReturn="True"
             VerticalScrollBarVisibility="Disabled" />
</kino:KinoResizer>

4. 結語

Resizer控件我平時也不會單獨使用,而是放在其它控件里面,例如Button:

由於這個控件性能也不高,以后還可能改進API,於是被放到了Primitives命名空間。

很久很久以前常常遇到“布局循環”這個錯誤,這常常出現在處理布局的代碼中。最近很久沒遇到這個錯誤,也許是WPF變健壯了,又也許是我的代碼變得優秀了。但是一朝被蛇咬十年怕草繩,所以我很少去碰Measure和Arrange的代碼,我也建議使用Measure和Arrange要慎重。

5. 參考

FrameworkElement.MeasureOverride(Size) Method (System.Windows) Microsoft Docs.html

UIElement.DesiredSize Property (System.Windows) Microsoft Docs.html

UIElement.InvalidateMeasure Method (System.Windows) Microsoft Docs

UIElement.IsMeasureValid Property (System.Windows) Microsoft Docs

6. 源碼

Kino.Toolkit.Wpf_Resizer at master


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM