1. 程式人生 > >[WPF自定義控制元件庫]瞭解WPF的佈局過程,並利用Measure為Expander新增動畫

[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 mas