UWP Composition API - GroupListView(一)
需求:
光看标题大家肯定不知道是什么东西,先上效果图:
这不就是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 元素