需求:
光看標題大家肯定不知道是什么東西,先上效果圖:
這不就是ListView的Group效果嗎?? 看上去是的。但是請聽完需求.
1.Group中的集合需要支持增量加載ISupportIncrementalLoading
2.支持UI Virtualization
oh,no。ListView 自帶的Group都不支持這2個需求。好吧,只有靠自己擼Code了。。
實現前思考:
仔細想了下,其實要解決的主要問題有2個
數據源的處理 和 GroupHeader的UI的處理
1.數據源的處理
因為之前在寫 UWP VirtualizedVariableSizedGridView 支持可虛擬化可變大小Item的View的時候已經做過這種處理源的工作了,所以方案出來的比較快。
不管有幾個group,其實當第1個hasMore等false的時候,我們就可以加載第2個group里面的集合。
我為此寫了一個類GroupObservableCollection<T> 它是繼承 ObservableCollection<T>, IGroupCollection

public class GroupObservableCollection<T> : ObservableCollection<T>, IGroupCollection { private List<IList<T>> souresList; private List<int> firstIndexInEachGroup = new List<int>(); private List<IGroupHeader> groupHeaders; bool _isLoadingMoreItems = false; public GroupObservableCollection(List<IList<T>> souresList, List<IGroupHeader> groupHeaders) { this.souresList = souresList; this.groupHeaders = groupHeaders; } public bool HasMoreItems { get { if (CurrentGroupIndex < souresList.Count) { var source = souresList[currentGroupIndex]; if (source is ISupportIncrementalLoading) { if (!(source as ISupportIncrementalLoading).HasMoreItems) { if (!_isLoadingMoreItems) { if (this.Count < GetSourceListTotoalCount()) { int count = 0; int preCount = this.Count; foreach (var item in souresList) { foreach (var item1 in item) { if (count >= preCount) { this.Add(item1); if (item == source && groupHeaders[currentGroupIndex].FirstIndex==-1) { groupHeaders[currentGroupIndex].FirstIndex = this.Count - 1; } } count++; } } } groupHeaders[currentGroupIndex].LastIndex = this.Count - 1; return false; } else { return true; } } else { return true; } } else { if (CurrentGroupIndex == source.Count - 1) { if (this.Count < GetSourceListTotoalCount()) { int count = 0; int preCount = this.Count; foreach (var item in souresList) { foreach (var item1 in item) { if (count >= preCount) { this.Add(item1); if (item == source && groupHeaders[currentGroupIndex].FirstIndex == -1) { groupHeaders[currentGroupIndex].FirstIndex = this.Count - 1; } } count++; } } } groupHeaders[currentGroupIndex].LastIndex = this.Count - 1; return false; } else { return true; } } } else { return false; } } } int GetSourceListTotoalCount() { int i = 0; foreach (var item in souresList) { i += item.Count; } return i; } public List<int> FirstIndexInEachGroup { get { return firstIndexInEachGroup; } set { firstIndexInEachGroup = value; } } public List<IGroupHeader> GroupHeaders { get { return groupHeaders; } set { groupHeaders = value; } } public IAsyncOperation<LoadMoreItemsResult> LoadMoreItemsAsync(uint count) { return FetchItems(count).AsAsyncOperation(); } private int currentGroupIndex; public int CurrentGroupIndex { get { int count = 0; for (int i = 0; i < souresList.Count; i++) { var source = souresList[i]; count += source.Count; if (count > this.Count) { currentGroupIndex = i; return currentGroupIndex; } else if (count == this.Count) { currentGroupIndex = i; if ((source is ISupportIncrementalLoading)) { if (!(source as ISupportIncrementalLoading).HasMoreItems) { if (!_isLoadingMoreItems) { groupHeaders[i].LastIndex = this.Count - 1; if (currentGroupIndex + 1 < souresList.Count) { currentGroupIndex = i + 1; } } } } else { //next if (currentGroupIndex + 1 < souresList.Count) { currentGroupIndex = i + 1; } } return currentGroupIndex; } else { continue; } } currentGroupIndex = 0; return currentGroupIndex; } } private async Task<LoadMoreItemsResult> FetchItems(uint count) { var source = souresList[CurrentGroupIndex]; if (source is ISupportIncrementalLoading) { int firstIndex = 0; if (groupHeaders[currentGroupIndex].FirstIndex != -1) { firstIndex = source.Count; } _isLoadingMoreItems = true; var result = await (source as ISupportIncrementalLoading).LoadMoreItemsAsync(count); for (int i = firstIndex; i < source.Count; i++) { this.Add(source[i]); if (i == 0) { groupHeaders[currentGroupIndex].FirstIndex = this.Count - 1; } } _isLoadingMoreItems = false; return result; } else { int firstIndex = 0; if (groupHeaders[currentGroupIndex].FirstIndex != -1) { firstIndex = source.Count; } for (int i = firstIndex; i < source.Count; i++) { this.Add(source[i]); if (i == 0) { groupHeaders[currentGroupIndex].FirstIndex = this.Count - 1; } } groupHeaders[currentGroupIndex].LastIndex = this.Count - 1; return new LoadMoreItemsResult() { Count = (uint)source.Count }; } } }
而IGroupCollection是個接口。
public interface IGroupCollection: ISupportIncrementalLoading { List<IGroupHeader> GroupHeaders { get; set; } int CurrentGroupIndex { get; } } public interface IGroupHeader { string Name { get; set; } int FirstIndex { get; set; } int LastIndex { get; set; } double Height { get; set; } } public class DefaultGroupHeader : IGroupHeader { public string Name { get; set; } public int FirstIndex { get; set; } public int LastIndex { get; set; } public double Height { get; set; } public DefaultGroupHeader() { FirstIndex = -1; LastIndex = -1; } }
IGroupHeader 是用來描述Group header的,你可以繼承它,添加一些綁定GroupHeader的屬性(注意請給FirstIndex和LastIndex賦值-1的初始值)
比如:在效果圖中,如果只有全部評論,沒有精彩評論,那么后面的導航的按鈕是應該不現實的,所以我加了GoToButtonVisibility屬性來控制。
public class MyGroupHeader : IGroupHeader, INotifyPropertyChanged { public string Name { get; set; } public int FirstIndex { get; set; } public int LastIndex { get; set; } public double Height { get; set; } public string GoTo { get; set; } private Visibility _goToButtonVisibility = Visibility.Collapsed; public Visibility GoToButtonVisibility { get { return _goToButtonVisibility; } set { _goToButtonVisibility = value; OnPropertyChanged("GoToButtonVisibility"); } } public MyGroupHeader() { FirstIndex = -1; LastIndex = -1; } public event PropertyChangedEventHandler PropertyChanged; void OnPropertyChanged(string propertyName) { if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } } }
數據源的處理還是比較簡單的。
2.GroupHeader的UI的處理
首先我想到的是加一個Grid,然后這些GroupHeader放在里面,通過ScrollViewer的ViewChanged來處理它們。
比較了下ListView的Group效果,Scrollbar是會擋住GroupHeader的,所以我把這個Grid放進了ScrollViewer的模板里面。
GroupListView的模板,這里大家可以看到我加入了個ProgressRing,這個是后面做導航功能需要的,后面再講。
<ControlTemplate TargetType="local:GroupListView"> <Grid BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}"> <ScrollViewer x:Name="ScrollViewer" Style="{StaticResource GroupListViewScrollViewer}" AutomationProperties.AccessibilityView="Raw" BringIntoViewOnFocusChange="{TemplateBinding ScrollViewer.BringIntoViewOnFocusChange}" HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}" HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" IsHorizontalRailEnabled="{TemplateBinding ScrollViewer.IsHorizontalRailEnabled}" IsHorizontalScrollChainingEnabled="{TemplateBinding ScrollViewer.IsHorizontalScrollChainingEnabled}" IsVerticalScrollChainingEnabled="{TemplateBinding ScrollViewer.IsVerticalScrollChainingEnabled}" IsVerticalRailEnabled="{TemplateBinding ScrollViewer.IsVerticalRailEnabled}" IsDeferredScrollingEnabled="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}" TabNavigation="{TemplateBinding TabNavigation}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}" ZoomMode="{TemplateBinding ScrollViewer.ZoomMode}"> <ItemsPresenter FooterTransitions="{TemplateBinding FooterTransitions}" FooterTemplate="{TemplateBinding FooterTemplate}" Footer="{TemplateBinding Footer}" HeaderTemplate="{TemplateBinding HeaderTemplate}" Header="{TemplateBinding Header}" HeaderTransitions="{TemplateBinding HeaderTransitions}" Padding="{TemplateBinding Padding}"/> </ScrollViewer> <ProgressRing x:Name="ProgressRing" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Grid> </ControlTemplate>
ScrollViewer的模板
<Grid Background="{TemplateBinding Background}"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="Auto"/> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <ScrollContentPresenter x:Name="ScrollContentPresenter" Grid.ColumnSpan="2" ContentTemplate="{TemplateBinding ContentTemplate}" Margin="{TemplateBinding Padding}" Grid.RowSpan="2"/> <Grid x:Name="GroupHeadersCanvas" Grid.RowSpan="2" Grid.ColumnSpan="2" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/> <ContentControl x:Name="TopGroupHeader" Grid.RowSpan="2" Grid.ColumnSpan="2" VerticalAlignment="Top" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch"/> <ScrollBar x:Name="VerticalScrollBar" Grid.Column="1" HorizontalAlignment="Right" IsTabStop="False" Maximum="{TemplateBinding ScrollableHeight}" Orientation="Vertical" Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}" Value="{TemplateBinding VerticalOffset}" ViewportSize="{TemplateBinding ViewportHeight}"/> <ScrollBar x:Name="HorizontalScrollBar" IsTabStop="False" Maximum="{TemplateBinding ScrollableWidth}" Orientation="Horizontal" Grid.Row="1" Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}" Value="{TemplateBinding HorizontalOffset}" ViewportSize="{TemplateBinding ViewportWidth}"/> <Border x:Name="ScrollBarSeparator" Background="{ThemeResource SystemControlPageBackgroundChromeLowBrush}" Grid.Column="1" Grid.Row="1"/> </Grid>
下面就是實現對GroupHeader顯示的控制了。
很快代碼寫好了。。運行起來效果還可以。。但是童鞋們說。。你這個跟Composition API 一毛錢關系都沒有啊。。
大家別急。。聽我說。。模擬器里面運行還行,拿實體機器上運行的時候,當我快速向上或者向下滑動的時候,GroupHeader會出現頓一頓的感覺,卡一下,不會有慣性的感覺。
看到這個,我立馬明白了。。不管是ViewChanging或者ViewChanged事件,它們跟Manipulation都不是同步的。
看了上一盤 UWP Composition API - PullToRefresh的童鞋會說,好吧,隱藏的真深。
那我們還是用Composition API來建立GroupHeader和ScrollViewer之間的關系。
1.首先我想的是,當進入Viewport再用Composition API來建立關系,但是很快被我否決了。還是因為ViewChanged這個事件是有慣性的原因,這樣沒法讓創建GroupHeader和ScrollViewer之間的關系的初始數據完全准確。
就是說GroupHeader因為初始數據不正確的情況會造成沒放在我想要的位置,只有當慣性停止的時候獲取的位置信息才是准確的。
在PrepareContainerForItemOverride中判斷是否GroupHeader 的那個Item已經准備添加到ItemsPanel里面。

protected override void PrepareContainerForItemOverride(DependencyObject element, object item) { base.PrepareContainerForItemOverride(element, item); ListViewItem listViewItem = element as ListViewItem; listViewItem.SizeChanged -= ListViewItem_SizeChanged; if (listViewItem.Tag == null) { defaultListViewItemMargin = listViewItem.Margin; } if (groupCollection != null) { var index = IndexFromContainer(element); var group = groupCollection.GroupHeaders.FirstOrDefault(x => x.FirstIndex == index || x.LastIndex == index); if (group != null) { if (!groupDic.ContainsKey(group)) { ContentControl groupheader = CreateGroupHeader(group); ContentControl tempGroupheader = CreateGroupHeader(group); ExpressionAnimationItem expressionAnimationItem = new ExpressionAnimationItem(); expressionAnimationItem.VisualElement = groupheader; expressionAnimationItem.TempElement = tempGroupheader; groupDic[group] = expressionAnimationItem; var temp = new Dictionary<IGroupHeader, ExpressionAnimationItem>(); foreach (var keyValue in groupDic.OrderBy(x => x.Key.FirstIndex)) { temp[keyValue.Key] = keyValue.Value; } groupDic = temp; if (groupHeadersCanvas != null) { groupHeadersCanvas.Children.Add(groupheader); groupHeadersCanvas.Children.Add(tempGroupheader); groupheader.Measure(new Windows.Foundation.Size(this.ActualWidth, this.ActualHeight)); group.Height = groupheader.DesiredSize.Height; groupheader.Height = tempGroupheader.Height = group.Height; groupheader.Width = tempGroupheader.Width = this.ActualWidth; if (group.FirstIndex == index) { listViewItem.Tag = listViewItem.Margin; listViewItem.Margin = GetItemMarginBaseOnDeafult(groupheader.DesiredSize.Height); listViewItem.SizeChanged += ListViewItem_SizeChanged; } groupheader.Visibility = Visibility.Collapsed; tempGroupheader.Visibility = Visibility.Collapsed; UpdateGroupHeaders(); } } else { if (group.FirstIndex == index) { listViewItem.Tag = listViewItem.Margin; listViewItem.Margin = GetItemMarginBaseOnDeafult(group.Height); listViewItem.SizeChanged += ListViewItem_SizeChanged; } else { listViewItem.Margin = defaultListViewItemMargin; } } } else { listViewItem.Margin = defaultListViewItemMargin; } } else { listViewItem.Margin = defaultListViewItemMargin; } }
在UpdateGroupHeader方法里面去設置Header的狀態

internal void UpdateGroupHeaders(bool isIntermediate = true) { var firstVisibleItemIndex = this.GetFirstVisibleIndex(); foreach (var item in groupDic) { //top header if (item.Key.FirstIndex <= firstVisibleItemIndex && (firstVisibleItemIndex <= item.Key.LastIndex || item.Key.LastIndex == -1)) { currentTopGroupHeader.Visibility = Visibility.Visible; currentTopGroupHeader.Margin = new Thickness(0); currentTopGroupHeader.Clip = null; currentTopGroupHeader.DataContext = item.Key; if (item.Key.FirstIndex == firstVisibleItemIndex) { if (item.Value.ScrollViewer == null) { item.Value.ScrollViewer = scrollViewer; } var isActive = item.Value.IsActive; item.Value.StopAnimation(); item.Value.VisualElement.Clip = null; item.Value.VisualElement.Visibility = Visibility.Collapsed; if (!isActive) { if (!isIntermediate) { item.Value.VisualElement.Margin = new Thickness(0); item.Value.StartAnimation(true); } } else { item.Value.StartAnimation(false); } } ClearTempElement(item); } //moving header else { HandleGroupHeader(isIntermediate, item); } } }
這里我簡單說下幾種狀態:
1. 在ItemsPanel里面
1)全部在Viewport里面
動畫開啟,Clip設置為Null
2)部分在Viewport里面
動畫開啟,並且設置Clip
3)沒有在viewport里面
動畫開啟,Visible 設置為Collapsed
2. 沒有在ItemsPanel里面
動畫停止。
關於GroupHeader初始狀態的設置,這里是最坑的,遇到很多問題。
public void StartAnimation(bool update = false) { if (update || expression == null || visual == null) { visual = ElementCompositionPreview.GetElementVisual(VisualElement); //if (0 <= VisualElement.Margin.Top && VisualElement.Margin.Top <= ScrollViewer.ActualHeight) //{ // min = (float)-VisualElement.Margin.Top; // max = (float)ScrollViewer.ActualHeight + min; //} //else if (VisualElement.Margin.Top < 0) //{ //} //else if (VisualElement.Margin.Top > ScrollViewer.ActualHeight) //{ //} if (scrollViewerManipProps == null) { scrollViewerManipProps = ElementCompositionPreview.GetScrollViewerManipulationPropertySet(ScrollViewer); } Compositor compositor = scrollViewerManipProps.Compositor; // Create the expression //expression = compositor.CreateExpressionAnimation("min(max((ScrollViewerManipProps.Translation.Y + VerticalOffset), MinValue), MaxValue)"); ////Expression = compositor.CreateExpressionAnimation("ScrollViewerManipProps.Translation.Y +VerticalOffset"); //expression.SetScalarParameter("MinValue", min); //expression.SetScalarParameter("MaxValue", max); //expression.SetScalarParameter("VerticalOffset", (float)ScrollViewer.VerticalOffset); expression = compositor.CreateExpressionAnimation("ScrollViewerManipProps.Translation.Y + VerticalOffset"); ////Expression = compositor.CreateExpressionAnimation("ScrollViewerManipProps.Translation.Y +VerticalOffset"); //expression.SetScalarParameter("MinValue", min); //expression.SetScalarParameter("MaxValue", max); VerticalOffset = ScrollViewer.VerticalOffset; expression.SetScalarParameter("VerticalOffset", (float)ScrollViewer.VerticalOffset); // set "dynamic" reference parameter that will be used to evaluate the current position of the scrollbar every frame expression.SetReferenceParameter("ScrollViewerManipProps", scrollViewerManipProps); } visual.StartAnimation("Offset.Y", expression); IsActive = true; //Windows.UI.Xaml.Media.CompositionTarget.Rendering -= OnCompositionTargetRendering; //Windows.UI.Xaml.Media.CompositionTarget.Rendering += OnCompositionTargetRendering; }
注釋掉了的代碼是處理:
當GroupHeader進入Viewport的時候才啟動動畫,離開之后就關閉動畫,表達式就是一個限制,這個就不講了。
expression = compositor.CreateExpressionAnimation("ScrollViewerManipProps.Translation.Y + VerticalOffset");
可以看到我給表達式加了一個VericalOffset。。嗯。其實Visual的Offset是表示 Visual 相對於其父 Visual 的位置偏移量。
舉2個例子,整個Viewport的高度是500,現在滾動條的VericalOffset是100。
1.如果我想把Header(header高度為50)放到Viewport的最下面(Header剛好全部進入Viewport),那么初始的參數應該是哪些呢?
Header.Margin = new Thickness(450);
Header.Clip=null;
expression = compositor.CreateExpressionAnimation("ScrollViewerManipProps.Translation.Y +100");
這樣向上滾ScrollViewerManipProps.Translation.Y(-450),Header 就會滾Viewport的頂部。
2.如果我想把Header(header高度為50)放到Viewport的最下面(Header剛好一半全部進入Viewport),那么初始的參數應該是哪些呢?
Header.Margin = new Thickness(475);
Header.Clip=new RectangleGeometry() { Rect = new Rect(0, 0, this.ActualWidth, 25) };
expression = compositor.CreateExpressionAnimation("ScrollViewerManipProps.Translation.Y +100");
當向上或者向下滾動的時候,記得更新Clip值就可以了。
說到為什么要加Clip,因為如果你的控件不是整個Page大小的時候,這個Header會顯示到控件外部去,大家應該都是懂得。
這里說下這個里面碰到一個問題。當GroupHeader Viewport之外的時候(在Grid之外的,Margin大於Grid的高度)創建動畫,會發現你怎么修改Header屬性都是沒有效果的。
最終結果的是不會在屏幕上顯示任何東西。
實驗了下用Canvas發現就可以了,但是Grid卻不行,是不是可以認為Visual在創建的時候如果對象不在它父容器的Size范圍之內,創建出來都是看不見的??
這個希望懂得童鞋能留言告訴一下。
把ScrollViewer模板里面的Grid換成Canvas就好了。。
剩下的都是一些計算,計算位置,計算大小變化。
最后就是GoToGroup方法,當跳轉的Group沒有load出來的時候(也就是FirstIndex還沒有值得時候),我們就Load,Load,Load,直到
它有值,這個可能是個長的時間過程,所以加了ProgressRing,找到Index,最后用ListView的API來跳轉就好了。
public async Task GoToGroupAsync(int groupIndex, ScrollIntoViewAlignment scrollIntoViewAlignment = ScrollIntoViewAlignment.Leading) { if (groupCollection != null) { var gc = groupCollection; if (groupIndex < gc.GroupHeaders.Count && groupIndex >= 0 && !isGotoGrouping) { isGotoGrouping = true; //load more so that ScrollIntoViewAlignment.Leading can go to top var loadcount = this.GetVisibleItemsCount() + 1; progressRing.IsActive = true; progressRing.Visibility = Visibility.Visible; //make sure user don't do any other thing at the time. this.IsHitTestVisible = false; //await Task.Delay(3000); while (gc.GroupHeaders[groupIndex].FirstIndex == -1) { if (gc.HasMoreItems) { await gc.LoadMoreItemsAsync(loadcount); } else { break; } } if (gc.GroupHeaders[groupIndex].FirstIndex != -1) { //make sure there are enought items to go ScrollIntoViewAlignment.Leading //this.count > (firstIndex + loadcount) if (scrollIntoViewAlignment == ScrollIntoViewAlignment.Leading) { var more = this.Items.Count - (gc.GroupHeaders[groupIndex].FirstIndex + loadcount); if (gc.HasMoreItems && more < 0) { await gc.LoadMoreItemsAsync((uint)Math.Abs(more)); } } progressRing.IsActive = false; progressRing.Visibility = Visibility.Collapsed; var groupFirstIndex = gc.GroupHeaders[groupIndex].FirstIndex; ScrollIntoView(this.Items[groupFirstIndex], scrollIntoViewAlignment); //already in viewport, maybe it will not change view if (groupDic.ContainsKey(gc.GroupHeaders[groupIndex]) && groupDic[gc.GroupHeaders[groupIndex]].Visibility == Visibility.Visible) { this.IsHitTestVisible = true; isGotoGrouping = false; } } else { this.IsHitTestVisible = true; isGotoGrouping = false; progressRing.IsActive = false; progressRing.Visibility = Visibility.Collapsed; } } } }
總結:
這個控件做下來,基本上都是在計算計算計算。。當然也知道了一些Composition API的東西。
其實Vistual的屬性還有很多,在做這個控件的時候沒有用到,以后用到了會繼續分享的。 開源有益,源碼GitHub地址。
UWP Composition API - GroupListView(二)
Visual 元素有些基本的呈現相關屬性,這些屬性都能使用 Composition API 的動畫 API 來演示動畫。
-
Opacity
表示 Visual 的透明度。 -
Offset
表示 Visual 相對於其父 Visual 的位置偏移量。 -
Clip
表示 Visual 裁剪區域。 -
CenterPoint
表示 Visual 的中心點。 -
TransformMatrix
表示 Visual 的變換矩陣。 -
Size
表示 Visual 的尺寸大小。 -
Scale
表示 Visual 的縮放大小。 -
RotationAxis
表示 Visual 的旋轉軸。 -
RotationAngle
表示 Visual 的旋轉角度。
有 4 個類派生自 Visual,他們分別對應了不同種類的 Visual,分別是:
-
ContainerVisual
表示容器 Visual,可能有子節點的 Visual,大部分的 XAML 可視元素基本都是該 Visual,其他的 Visual 都也是派生自該類。 -
EffectVisual
表示通過特效來呈現內容的 Visual,可以通過配合 Win2D 的支持 Composition 的 Effects 來呈現豐富多彩的內容。 -
ImageVisual
表示通過圖片來呈現內容的 Visual,可以用於呈現圖片。 -
SolidColorVisual
表示一個純色矩形的 Visual 元素