背景:
之前用ScrollViewer 來做過 PullToRefresh的控件,在項目一些特殊的條件下總有一些問題,比如ScrollViewer不會及時到達指定位置。
於是便有了使用Composition API來重新實現PullToRefresh控件。本控件的難點不是實現,而是對Composition API的一些探索。
本文的一些觀點或者說結論不一定是全對的,都是通過實驗得到的,Composition API 可用的資料實在是太少了。
成品效果圖:
資料:
Composition API 資料
1.官方Sample
2. 原作者 Nick Waggoner 供職於微軟 native Windows UI platform 鏈接是對文章的翻譯 VALID VOID
3.比較舊的資料
在網上查閱了些資料,看到網上有大神已經實現過了。了解了大概的實現過程,因為自己想做的效果還是跟大神的有差距的,所以
還是自己動手封裝成控件。
實現原理:
這里引用 VALID VOID里面的話:
輸入驅動動畫
自大約五年前觸摸漸成主流起,創造低延遲體驗成為了一種普遍需求。使用手指或筆在屏幕上操作,使得人眼獲得了更直觀的參照點來辨識操作的延遲和流暢性。為使操作流暢,主流操作系統公司均將更多的操作移交至系統和 GPU (如 Chrome、IE)執行。在 Windows 上,這由 DirectManipulation 這一或多或少是針對於觸摸構建的動畫引擎實現的。它解決了關鍵的延遲挑戰,也就是如何自然地以展示從輸入驅動到事件驅動過渡的動效。但另一方面,它也幾乎沒有提供對定制慣性觀感的支持,就像福特 T 型車那樣——“只要車是黑色的,你可以把它塗成任意你喜歡的顏色”。2
ElementCompositionPreview.GetScrollViewerManipulationPropertySet
是讓你能夠把玩輸入驅動動效的第一步。雖然它仍然沒給你任何對內容滾動時觀感進行控制的額外能力,但它確實允許你對次級內容應用表達式動畫。例如,我們終於能完成我們的基礎視差滾動代碼:
// 創建驅動視差滾動的表達式動畫。 ExpressionAnimation parallaxAnimation = compositor.CreateExpressionAnimation("MyForeground.Translation.Y / MyParallaxRatio"); // 設置對前景對象的引用。 CompositionPropertySet MyPropertySet = ElementCompositionPreview.GetScrollViewerManipulationPropertySet(MyScrollViewer); parallaxAnimation.SetReferenceParameter("MyForeground", MyPropertySet); // 設置背景對象視差滾動的速度。 parallaxAnimation.SetScalarParameter("MyParallaxRatio", 0.5f); // 對背景對象開始視差動畫。 backgroundVisual.StartAnimation("Offset.Y", parallaxAnimation);
使用這一技巧,你能夠實現多種優秀的效果:視差滾動、粘性表頭、自定義滾動條等等。唯一缺失的就是定制操作本身的觀感……
我講一下我的理解:使用過ScrollViewer 和Manipulation相關事件的童鞋都知道,想要得到一些ScrollViewer 觸摸的詳情太難了,
DirectManipulationStarted和DirectManipulationCompleted得到信息太少了,而Manipulation其他的事件又需要設置ManipulationMode,這樣全部的情況都要你自己來處理。當看到
ElementCompositionPreview.GetScrollViewerManipulationPropertySet(MyScrollViewer);
的時候,你是不是感覺有點親切。看上去你拿到了ScrollViewer 的一些Manipulation 的信息。。話說這里是最最坑爹了,
MyForeground 其實是GetScrollViewerManipulationPropertySet返回的東東,
但MyForeground.Translation.Y這是什么鬼東西。。Manipulation 的Translation??? 后來我在網上搜索了一下,
但我查了下CompositionPropertySet 並沒發現有相關Translation的屬性啊?
難道跟Manipulation事件里面的參數里面的Translation 是一樣的嗎??這是我的推測。
網上都是這樣用的,但是沒有文檔。。除了Translation不知道還有其他屬性能使用不。
暫時沒有尋找到答案,希望知道的童鞋可以留言,萬分感激。。。
上面這段的代碼的意思就是說把Manipulation.Translation.Y 映射到backgroundVisual的Offset.Y上面。
也就是說你現在已經可以找到ScrollViewer滾動的時候一些有用的數值了。。值得說的是,用鼠標滾動的時候 映射依然能生效。
這種映射是實時的,是會有慣性效果的。
實現過程:
因為要用到ScrollViewer,所以我做了2種,一種是刷新內容第一元素是ScrollViewer的,一種不是ScrollViewer的。
如果刷新內容第一元素是ScrollViewer,模板如下:
<ControlTemplate TargetType="local:PullToRefreshGrid1"> <Grid> <ContentControl x:Name="Header" Opacity="0" VerticalAlignment="Top" ContentTemplate="{TemplateBinding HeaderTemplate}" HorizontalContentAlignment="Center" VerticalContentAlignment="Bottom" /> <ContentPresenter x:Name="Content" ContentTemplate="{TemplateBinding ContentTemplate}" ContentTransitions="{TemplateBinding ContentTransitions}" Content="{TemplateBinding Content}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" Margin="{TemplateBinding Padding}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> </Grid> </ControlTemplate>
如果刷新內容第一元素不是ScrollViewer,那么我為它添加了一個ScrollViewer:
<ControlTemplate TargetType="local:PullToRefreshGrid"> <Grid> <ContentControl x:Name="Header" Opacity="0" VerticalAlignment="Top" ContentTemplate="{TemplateBinding HeaderTemplate}" HorizontalContentAlignment="Center" VerticalContentAlignment="Bottom" /> <ScrollViewer x:Name="ScrollViewer" VerticalSnapPointsType="MandatorySingle" VerticalSnapPointsAlignment="Near" VerticalScrollMode="Enabled" VerticalScrollBarVisibility="Hidden" VerticalContentAlignment="Stretch" VerticalAlignment="Stretch"> <ContentPresenter x:Name="Content" ContentTemplate="{TemplateBinding ContentTemplate}" ContentTransitions="{TemplateBinding ContentTransitions}" Content="{TemplateBinding Content}" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" Margin="{TemplateBinding Padding}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> </ScrollViewer> </Grid> </ControlTemplate>
當然,其實第2種也是統用的,我只是想減少控件的Child,如果你不知道刷新內容里面有沒有ScrollViewer,那么用第2種就好了。有人會說為啥控件的名字這么奇怪。。那是因為我之前也寫過PullToRefresh控件(PullToRefreshControl,PullToRefreshPanel),我把名字都想完了。。實在想不出更好的了。。大家體諒下。。( ╯□╰ )
好了,重點就是拿到這個ScrollViewer 然后跟Header,產生某種關系(你懂的)。。。
if (RefreshThreshold == 0.0) { RefreshThreshold = headerHeight; } ratio = RefreshThreshold / headerHeight; _offsetAnimation = _compositor.CreateExpressionAnimation("(min(max(0, ScrollManipulation.Translation.Y * ratio) / Divider, 1)) * MaxOffsetY"); _offsetAnimation.SetScalarParameter("Divider", (float)RefreshThreshold); _offsetAnimation.SetScalarParameter("MaxOffsetY", (float)RefreshThreshold * 5 / 4); _offsetAnimation.SetScalarParameter("ratio", (float)ratio); _offsetAnimation.SetReferenceParameter("ScrollManipulation", _scrollerViewerManipulation); _opacityAnimation = _compositor.CreateExpressionAnimation("min((max(0, ScrollManipulation.Translation.Y * ratio) / Divider), 1)"); _opacityAnimation.SetScalarParameter("Divider", (float)headerHeight); _opacityAnimation.SetScalarParameter("ratio", (float)1); _opacityAnimation.SetReferenceParameter("ScrollManipulation", _scrollerViewerManipulation); _headerVisual = ElementCompositionPreview.GetElementVisual(_header); _contentVisual = ElementCompositionPreview.GetElementVisual(_scrollViewerBorder); _headerVisual.StartAnimation("Offset.Y", _offsetAnimation); _headerVisual.StartAnimation("Opacity", _opacityAnimation); _contentVisual.StartAnimation("Offset.Y", _offsetAnimation);
RefreshThreshold 是到達Release to refresh的一個點。。可以由用戶設定,默認是header的高度。
MaxOffsetY 是當到達RefreshThreshold之后我還能拖動的最大值,這里設置為RefreshThreshold 的5/4。
(min(max(0, ScrollManipulation.Translation.Y * ratio) / Divider, 1)) * MaxOffsetY
我再來講講這個表達式的意思,ScrollManipulation.Translation.Y 大家都已經知道了。是ScrollViewer進行Manipulation的Y值,向下是正值,向上時負值,初始為0.
綜合起來就是最大值為MaxOffsetY最小值為0.。這個速率是根據RefreshThreshold/headerHeight來的,因為你會發現,你向下drag scrollViewer的時候是有一定最大值的,當RefreshThreshold比較大的時候,你很難ScrollManipulation.Translation.Y值達到RefreshThreshold。
min((max(0, ScrollManipulation.Translation.Y * ratio) / Divider), 1)
這個也比較簡單,是給header的Opaicty做了一個動畫。
_headerVisual.StartAnimation("Offset.Y", _offsetAnimation); _headerVisual.StartAnimation("Opacity", _opacityAnimation); _contentVisual.StartAnimation("Offset.Y", _offsetAnimation);
完成之后我們將動畫開始就好了。這樣在向下拖scrollviewer的時候,scrollviewer和header就都會向下移動。
接下來我們需要監聽 offset。
private void ScrollViewer_DirectManipulationStarted(object sender, object e) { Windows.UI.Xaml.Media.CompositionTarget.Rendering += OnCompositionTargetRendering; _refresh = false; _header.Opacity = 1; }
在開始manipulat的時候,注冊CompositionTarget.Rendering事件。在這個事件里面我們就可以實時獲得offset的變化了。
private void OnCompositionTargetRendering(object sender, object e) { _headerVisual.StopAnimation("Offset.Y"); var offsetY = _headerVisual.Offset.Y; IsReachThreshold = offsetY >= RefreshThreshold; _scrollViewerBorder.Clip = new RectangleGeometry() { Rect = new Rect(0, 0, _content.Width, _content.Height - offsetY) }; Debug.WriteLine(IsReachThreshold + "," + _headerVisual.Offset.Y + "," + RefreshThreshold); _headerVisual.StartAnimation("Offset.Y", _offsetAnimation); if (!_refresh) { _refresh = IsReachThreshold; } if (_refresh) { _pulledDownTime = DateTime.Now; } if (_refresh && offsetY <= 1) { _releaseTime = DateTime.Now; } }
這里有點坑爹的是,發現如果不StopAnimation,那么Offset.Y永遠都是0.。。很囧啊。。
最后我們在ScrollViewer_DirectManipulationCompleted事件里面處理是否要 觸發PullToRefresh事件就ok了。
private void ScrollViewer_DirectManipulationCompleted(object sender, object e) { Windows.UI.Xaml.Media.CompositionTarget.Rendering -= OnCompositionTargetRendering; var cancelled = (_releaseTime - _pulledDownTime) > TimeSpan.FromMilliseconds(250); if (_refresh) { _refresh = false; if (cancelled) { Debug.WriteLine("Refresh cancelled..."); } else { Debug.WriteLine("Refresh now!!!"); if (PullToRefresh != null) { _headerVisual.StopAnimation("Offset.Y"); LastRefreshTime = DateTime.Now; _headerVisual.StartAnimation("Offset.Y", _offsetAnimation); PullToRefresh(this, null); } } } }
最后說下Header模板(HeaderTemplate)是可以定義的。。它的DataContext是綁定到這個控件上的,有用的屬性有(LastRefreshTime,IsReachThreshold等),你可以用它們創造你屬於你喜歡的Header樣式。
通過本控件,初步了解Composition API 的一些用法。下一篇,我會講講一些更多的探索。。
開源有益,源碼GitHub地址。
問題:
1.Visual 是繼承IDisposable,我們需要在什么時候Dispose 掉它呢? 還是它自己管理的?
我試過在unload的時候去dispose它。但是會出了Win32的異常。。官方sample里面對這個也講的不清楚。
希望知道的童鞋能留言告知,萬分感謝。另外希望有對這個比較了解的童鞋能提供一些sample,資料,再次感謝。
補充:
使用條件:
// Windows build 10240 and later.
if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 1))
{
...
}
// Windows build10586 and later.
if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 2))
{
...
}
// Windows build14332 and later.
if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 3))
{
...
}
調試了下
1. Windows build14332 and later: 1,2,3都為true。
2. Windows build10586 and later: 1,2為true。
3. Windows build 10240 and later: 1為true。
因為10586之前的版本是不支持Composition API的。所以使用的時候記得判斷:
// Windows build10586 and later.
if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 2))
{
...
}