需求: 光看標題大家肯定不知道是什麼東西,先上效果圖: 這不就是ListView的Group效果嗎?? 看上去是的。但是請聽完需求.1.Group中的集合需要支持增量載入ISupportIncrementalLoading 2.支持UI Virtualization oh,no。ListView 自 ...
需求:
光看標題大家肯定不知道是什麼東西,先上效果圖:
這不就是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 }; } } }View Code
而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; } }View Code
在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); } } }View Code
這裡我簡單說下幾種狀態:
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() +