diff --git a/components/Behaviors/OpenSolution.bat b/components/Behaviors/OpenSolution.bat new file mode 100644 index 000000000..814a56d4b --- /dev/null +++ b/components/Behaviors/OpenSolution.bat @@ -0,0 +1,3 @@ +@ECHO OFF + +powershell ..\..\tooling\ProjectHeads\GenerateSingleSampleHeads.ps1 -componentPath %CD% %* \ No newline at end of file diff --git a/components/Behaviors/samples/Assets/icon.png b/components/Behaviors/samples/Assets/icon.png new file mode 100644 index 000000000..8435bcaa9 Binary files /dev/null and b/components/Behaviors/samples/Assets/icon.png differ diff --git a/components/Behaviors/samples/Behaviors.Samples.csproj b/components/Behaviors/samples/Behaviors.Samples.csproj new file mode 100644 index 000000000..82315d71c --- /dev/null +++ b/components/Behaviors/samples/Behaviors.Samples.csproj @@ -0,0 +1,23 @@ + + + + + Behaviors + + + + + + + + + + + + + + + + + + diff --git a/components/Behaviors/samples/Dependencies.props b/components/Behaviors/samples/Dependencies.props new file mode 100644 index 000000000..d7912ab7d --- /dev/null +++ b/components/Behaviors/samples/Dependencies.props @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/Behaviors/samples/ItemsView Behaviors.md b/components/Behaviors/samples/ItemsView Behaviors.md new file mode 100644 index 000000000..0ccc7054b --- /dev/null +++ b/components/Behaviors/samples/ItemsView Behaviors.md @@ -0,0 +1,31 @@ +--- +title: ItemsView Behaviors +author: githubaccount +description: A set of behaviors for the ItemsView control. +keywords: Behaviors, Control, Behavior +dev_langs: + - csharp +category: Xaml +subcategory: Behaviors +discussion-id: 532 +issue-id: 0 +icon: assets/icon.png +--- +The new control `ItemsView` which can replace `ListView`&`GridView`, does not support `ISupportIncrementalLoading`. + +Here are some Behaviors to help you make `ItemsView` support `ISupportIncrementalLoading`. + +## NeedMoreItemTriggerBehaviorSample + +This trigger behavior can excute actions when the `ItemsView` scrolling to bottom. +You can customize the loading behavior through actions, but note that you may need to debounce manually. + +> [!SAMPLE NeedMoreItemTriggerBehaviorSample] + +## LoadMoreItemBehaviorSample + +If you don't have complex loading requirements, you can use this behavior. +It automatically calls `ISupportIncrementalLoading.LoadMoreItemsAsync` when the `ItemsSource` changes, in addition to when `ItemsView` scrolls to the bottom. +Besides, this behavior has a built-in debounce function. + +> [!SAMPLE LoadMoreItemBehaviorSample] diff --git a/components/Behaviors/samples/LoadMoreItemBehaviorSample.xaml b/components/Behaviors/samples/LoadMoreItemBehaviorSample.xaml new file mode 100644 index 000000000..c10cccc11 --- /dev/null +++ b/components/Behaviors/samples/LoadMoreItemBehaviorSample.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/Behaviors/samples/LoadMoreItemBehaviorSample.xaml.cs b/components/Behaviors/samples/LoadMoreItemBehaviorSample.xaml.cs new file mode 100644 index 000000000..e322b0373 --- /dev/null +++ b/components/Behaviors/samples/LoadMoreItemBehaviorSample.xaml.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if WINAPPSDK + +using CommunityToolkit.WinUI.Controls; +using Microsoft.UI; + +namespace BehaviorsExperiment.Samples; + +[ToolkitSampleBoolOption(nameof(LoadMoreItemBehavior.IsActive), true, Title = nameof(LoadMoreItemBehavior.IsActive))] +[ToolkitSampleNumericOption(nameof(LoadMoreItemBehavior.LoadingOffset), 100d, 50d, 500d, 50d, Title = nameof(LoadMoreItemBehavior.LoadingOffset))] +[ToolkitSampleNumericOption(nameof(LoadMoreItemBehavior.LoadCount), 20, 1, 50, 1, Title = nameof(LoadMoreItemBehavior.LoadCount))] + +[ToolkitSample(id: nameof(LoadMoreItemBehaviorSample), "LoadMoreItemBehavior", description: $"A sample for showing how to create and use a LoadMoreItemBehavior.")] +public sealed partial class LoadMoreItemBehaviorSample : Page +{ + private static readonly Random _random = new Random(); + + public LoadMoreItemBehaviorSample() + { + this.InitializeComponent(); + } + + public static SolidColorBrush GetColor() + { + var rand = _random.Next(4); + var color = rand switch + { + 0 => Colors.Red, + 1 => Colors.Blue, + 2 => Colors.Green, + 3 => Colors.Yellow, + _ => Colors.Black + }; + return new(color); + } + + public MyCollection ViewModels { get; } = [.. Enumerable.Repeat(0, 50).Select(_ => _random.Next(5, 10) * 500)]; +} + +public partial class MyCollection : ObservableCollection, ISupportIncrementalLoading +{ + private static readonly Random _random = new Random(); + + public int Num + { + get => _num; + set + { + if (_num == value) + { + return; + } + + _num = value; + OnPropertyChanged(new PropertyChangedEventArgs(nameof(Num))); + } + } + + private int _num; + + public bool HasMoreItems => true; + + public IAsyncOperation LoadMoreItemsAsync(uint count) + { + return LoadMoreItemsTaskAsync(count).AsAsyncOperation(); + } + + public async Task LoadMoreItemsTaskAsync(uint count) + { + await Task.Delay(1000); + foreach (var i in Enumerable.Repeat(0, (int)count).Select(_ => _random.Next(5, 10) * 500)) + { + Add(i); + } + + Num++; + return new LoadMoreItemsResult { Count = count }; + } +} + +#endif diff --git a/components/Behaviors/samples/NeedMoreItemTriggerBehaviorSample.xaml b/components/Behaviors/samples/NeedMoreItemTriggerBehaviorSample.xaml new file mode 100644 index 000000000..b3c5c402b --- /dev/null +++ b/components/Behaviors/samples/NeedMoreItemTriggerBehaviorSample.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/Behaviors/samples/NeedMoreItemTriggerBehaviorSample.xaml.cs b/components/Behaviors/samples/NeedMoreItemTriggerBehaviorSample.xaml.cs new file mode 100644 index 000000000..090597d43 --- /dev/null +++ b/components/Behaviors/samples/NeedMoreItemTriggerBehaviorSample.xaml.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if WINAPPSDK + +using CommunityToolkit.WinUI.Controls; +using Microsoft.UI; + +namespace BehaviorsExperiment.Samples; + +[ToolkitSampleBoolOption(nameof(NeedMoreItemTriggerBehavior.IsActive), true, Title = nameof(NeedMoreItemTriggerBehavior.IsActive))] +[ToolkitSampleNumericOption(nameof(NeedMoreItemTriggerBehavior.LoadingOffset), 100d, 50d, 500d, 50d, Title = nameof(NeedMoreItemTriggerBehavior.LoadingOffset))] + +[ToolkitSample(id: nameof(NeedMoreItemTriggerBehaviorSample), "NeedMoreItemTriggerBehavior", description: $"A sample for showing how to create and use a NeedMoreItemTriggerBehavior.")] +public sealed partial class NeedMoreItemTriggerBehaviorSample : Page +{ + private static readonly Random _random = new(); + + public NeedMoreItemTriggerBehaviorSample() + { + this.InitializeComponent(); + } + + public static SolidColorBrush GetColor() + { + var rand = _random.Next(4); + var color = rand switch + { + 0 => Colors.Red, + 1 => Colors.Blue, + 2 => Colors.Green, + 3 => Colors.Yellow, + _ => Colors.Black + }; + return new(color); + } + + public IEnumerable ViewModels { get; } = Enumerable.Repeat(0, 50).Select(_ => _random.Next(5, 10) * 500); + + private int _num; + + public void IncrementCount() + { + _num++; + MyRun.Text = _num.ToString(); + } +} + +#endif diff --git a/components/Behaviors/src/CommunityToolkit.WinUI.Controls.Behaviors.csproj b/components/Behaviors/src/CommunityToolkit.WinUI.Controls.Behaviors.csproj new file mode 100644 index 000000000..43551ee24 --- /dev/null +++ b/components/Behaviors/src/CommunityToolkit.WinUI.Controls.Behaviors.csproj @@ -0,0 +1,13 @@ + + + + + Behaviors + This package contains Behaviors. + + CommunityToolkit.WinUI.Controls.BehaviorsRns + + + + + diff --git a/components/Behaviors/src/Dependencies.props b/components/Behaviors/src/Dependencies.props new file mode 100644 index 000000000..d7912ab7d --- /dev/null +++ b/components/Behaviors/src/Dependencies.props @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/Behaviors/src/LoadMoreItemBehavior.cs b/components/Behaviors/src/LoadMoreItemBehavior.cs new file mode 100644 index 000000000..7d3ee7c89 --- /dev/null +++ b/components/Behaviors/src/LoadMoreItemBehavior.cs @@ -0,0 +1,244 @@ +#region Copyright + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#endregion + +#if WINAPPSDK + +using System.Collections; +using System.Collections.Specialized; +using Microsoft.Xaml.Interactivity; + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A behavior that makes support . +/// +public class LoadMoreItemBehavior : Behavior +{ + /// + /// Identifies the property. + /// + public static readonly DependencyProperty LoadingOffsetProperty = DependencyProperty.Register( + nameof(LoadingOffset), + typeof(double), + typeof(LoadMoreItemBehavior), + new PropertyMetadata(100d)); + + /// + /// Gets or sets Distance of content from scrolling to bottom. + /// + public double LoadingOffset + { + get => (double)this.GetValue(LoadingOffsetProperty); + set => this.SetValue(LoadingOffsetProperty, value); + } + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty IsActiveProperty = DependencyProperty.Register( + nameof(IsActive), + typeof(bool), + typeof(LoadMoreItemBehavior), + new PropertyMetadata(true)); + + /// + /// Gets a value indicating whether the behavior is active. + /// + public bool IsActive + { + get => (bool)this.GetValue(IsActiveProperty); + set => this.SetValue(IsActiveProperty, value); + } + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty IsLoadingMoreProperty = DependencyProperty.Register( + nameof(IsLoadingMore), + typeof(bool), + typeof(LoadMoreItemBehavior), + new PropertyMetadata(false)); + + /// + /// Gets or sets if more items are being loaded. + /// + public bool IsLoadingMore + { + get => (bool)this.GetValue(IsLoadingMoreProperty); + set => this.SetValue(IsLoadingMoreProperty, value); + } + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty LoadCountProperty = DependencyProperty.Register( + nameof(LoadCount), + typeof(int), + typeof(LoadMoreItemBehavior), + new PropertyMetadata(20)); + + /// + /// Gets or sets the "count" parameter when triggering . + /// + public int LoadCount + { + get => (int)this.GetValue(LoadCountProperty); + set => this.SetValue(LoadCountProperty, value); + } + + /// + /// Raised when more items need to be loaded. + /// + public event Func>? LoadMoreRequested; + + private ItemsRepeater? ItemsRepeater => (ItemsRepeater?)this.ScrollView?.Content; + + private ScrollView? ScrollView => this.AssociatedObject?.ScrollView; + + private long _scrollViewOnPropertyChangedToken; + private long _itemsSourceOnPropertyChangedToken; + + private INotifyCollectionChanged? _lastObservableCollection; + + /// + protected override void OnAttached() + { + this.LoadMoreRequested += async (sender, args) => + { + if (sender.ItemsSource is ISupportIncrementalLoading sil) + { + _ = await sil.LoadMoreItemsAsync((uint)this.LoadCount); + return sil.HasMoreItems; + } + + return false; + }; + this._scrollViewOnPropertyChangedToken = this.AssociatedObject.RegisterPropertyChangedCallback(ItemsView.ScrollViewProperty, this.ScrollViewOnPropertyChanged); + this._itemsSourceOnPropertyChangedToken = this.RegisterPropertyChangedCallback(ItemsView.ItemsSourceProperty, this.ItemsSourceOnPropertyChanged); + } + + /// + protected override void OnDetaching() + { + this.AssociatedObject.UnregisterPropertyChangedCallback(ItemsView.ScrollViewProperty, this._scrollViewOnPropertyChangedToken); + this.AssociatedObject.UnregisterPropertyChangedCallback(ItemsView.ItemsSourceProperty, this._itemsSourceOnPropertyChangedToken); + if (this._lastObservableCollection is not null) + this._lastObservableCollection.CollectionChanged -= this.TryRaiseLoadMoreRequested; + if (this.ItemsRepeater is not null) + this.ItemsRepeater.SizeChanged -= this.TryRaiseLoadMoreRequested; + if (this.ScrollView is not null) + this.ScrollView.ViewChanged -= this.TryRaiseLoadMoreRequested; + } + + /// + /// When the data source changes or , . + /// This method reloads the data. + /// This method is intended to solve the problem of reloading data when the data source changes and the 's does not change + /// + private async void ItemsSourceOnPropertyChanged(DependencyObject sender, DependencyProperty dp) + { + if (sender is ItemsView { ItemsSource: ISupportIncrementalLoading sil }) + { + if (sil is INotifyCollectionChanged ncc) + { + if (this._lastObservableCollection is not null) + this._lastObservableCollection.CollectionChanged -= this.TryRaiseLoadMoreRequested; + + this._lastObservableCollection = ncc; + ncc.CollectionChanged += this.TryRaiseLoadMoreRequested; + } + + // On the first load, the `ScrollView` is not yet initialized and can be given to `AdvancedItemsView_OnSizeChanged` to trigger. + if (this.ScrollView is not null) + await this.TryRaiseLoadMoreRequestedAsync(); + } + } + + private async void TryRaiseLoadMoreRequested(object? sender, object e) => await this.TryRaiseLoadMoreRequestedAsync(); + + /// + /// After this method, the will be triggered + /// + /// + /// is the key to continuous loading. + /// When new data is loaded, it makes the of the larger, + /// Or when the is changed to a different source or set for the first time, becomes 0. + /// This method can reload the data. + /// + private void ScrollViewOnPropertyChanged(DependencyObject sender, DependencyProperty dp) + { + this.AssociatedObject.UnregisterPropertyChangedCallback(ItemsView.ScrollViewProperty, this._scrollViewOnPropertyChangedToken); + if (this.ScrollView is null) + return; + this.ScrollView.ViewChanged += this.TryRaiseLoadMoreRequested; + if (this.ItemsRepeater is not null) + this.ItemsRepeater.SizeChanged += this.TryRaiseLoadMoreRequested; + } + + /// + /// Determines if the scroll view has scrolled to the bottom, and if so triggers the . + /// This event will only cause the source to load at most once + /// + public async Task TryRaiseLoadMoreRequestedAsync() + { + if (this.AssociatedObject is null || this.ScrollView is null) + return; + + var loadMore = true; + // Load until a new item is loaded in + while (loadMore) + { + if (this.AssociatedObject is null || !this.IsActive || this.IsLoadingMore) + return; + + // LoadMoreRequested is only triggered when the view is not filled. + if ((this.ScrollView.ScrollableHeight is 0 && this.ScrollView.ScrollableWidth is 0) || + (this.ScrollView.ScrollableHeight > 0 && + this.ScrollView.ScrollableHeight - this.LoadingOffset < this.ScrollView.VerticalOffset) || + (this.ScrollView.ScrollableWidth > 0 && + this.ScrollView.ScrollableWidth - this.LoadingOffset < this.ScrollView.HorizontalOffset)) + { + this.IsLoadingMore = true; + var before = this.GetItemsCount(); + if (this.LoadMoreRequested is not null && await this.LoadMoreRequested(this.AssociatedObject, EventArgs.Empty)) + { + var after = this.GetItemsCount(); + // This can be set to the count of items in a row, + // so that it can continue to load even if the count of items loaded is too small. + // Generally, 20 items will be loaded at a time, + // and the count of items in a row is usually less than 10, so it is set to 10 here. + if (before + 10 <= after) + loadMore = false; + } + // No more items or ItemsSource is null + else + loadMore = false; + + this.IsLoadingMore = false; + } + else + { + // There is no need to continue loading if it fills up the view + loadMore = false; + } + } + } + + private int GetItemsCount() + { + return this.AssociatedObject?.ItemsSource switch + { + ICollection list => list.Count, + IEnumerable enumerable => enumerable.Cast().Count(), + null => 0, + _ => throw new ArgumentOutOfRangeException(nameof(this.AssociatedObject.ItemsSource)) + }; + } +} + +#endif diff --git a/components/Behaviors/src/MultiTarget.props b/components/Behaviors/src/MultiTarget.props new file mode 100644 index 000000000..b11c19426 --- /dev/null +++ b/components/Behaviors/src/MultiTarget.props @@ -0,0 +1,9 @@ + + + + uwp;wasdk;wpf;wasm;linuxgtk;macos;ios;android; + + \ No newline at end of file diff --git a/components/Behaviors/src/NeedMoreItemTriggerBehavior.cs b/components/Behaviors/src/NeedMoreItemTriggerBehavior.cs new file mode 100644 index 000000000..9fb0a1515 --- /dev/null +++ b/components/Behaviors/src/NeedMoreItemTriggerBehavior.cs @@ -0,0 +1,119 @@ +#region Copyright + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#endregion + +#if WINAPPSDK + +using Microsoft.Xaml.Interactivity; + +namespace CommunityToolkit.WinUI.Controls; + +/// +/// A behavior that triggers actions when the is scrolled to the bottom. +/// +[TypeConstraint(typeof(ItemsView))] +public class NeedMoreItemTriggerBehavior : Trigger +{ + /// + /// Identifies the property. + /// + public static readonly DependencyProperty LoadingOffsetProperty = DependencyProperty.Register( + nameof(LoadingOffset), + typeof(double), + typeof(NeedMoreItemTriggerBehavior), + new PropertyMetadata(100d)); + + /// + /// Gets or sets Distance of content from scrolling to bottom. + /// + public double LoadingOffset + { + get => (double)this.GetValue(LoadingOffsetProperty); + set => this.SetValue(LoadingOffsetProperty, value); + } + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty IsActiveProperty = DependencyProperty.Register( + nameof(IsActive), + typeof(bool), + typeof(NeedMoreItemTriggerBehavior), + new PropertyMetadata(true)); + + /// + /// Gets a value indicating whether the trigger is active. + /// + public bool IsActive + { + get => (bool)this.GetValue(IsActiveProperty); + set => this.SetValue(IsActiveProperty, value); + } + + private ItemsRepeater? ItemsRepeater => (ItemsRepeater?)this.ScrollView?.Content; + + private ScrollView? ScrollView => this.AssociatedObject.ScrollView; + + private long _scrollViewOnPropertyChangedToken; + + /// + protected override void OnAttached() + { + this._scrollViewOnPropertyChangedToken = this.AssociatedObject.RegisterPropertyChangedCallback(ItemsView.ScrollViewProperty, this.ScrollViewOnPropertyChanged); + } + + /// + protected override void OnDetaching() + { + this.AssociatedObject.UnregisterPropertyChangedCallback(ItemsView.ScrollViewProperty, this._scrollViewOnPropertyChangedToken); + if (this.ItemsRepeater is not null) + this.ItemsRepeater.SizeChanged -= this.TryRaiseLoadMoreRequested; + if (this.ScrollView is not null) + this.ScrollView.ViewChanged -= TryRaiseLoadMoreRequested; + } + + private void TryRaiseLoadMoreRequested(object? sender, object e) => this.TryRaiseLoadMoreRequested(); + + /// + /// After this method, the will be triggered + /// + /// + /// is the key to continuous loading. + /// When new data is loaded, it makes the of the larger, + /// Or when the is changed to a different source or set for the first time, becomes 0. + /// This method can reload the data. + /// + private void ScrollViewOnPropertyChanged(DependencyObject sender, DependencyProperty dp) + { + this.AssociatedObject.UnregisterPropertyChangedCallback(ItemsView.ScrollViewProperty, this._scrollViewOnPropertyChangedToken); + if (this.ScrollView is null) + return; + this.ScrollView.ViewChanged += TryRaiseLoadMoreRequested; + if (this.ItemsRepeater is not null) + this.ItemsRepeater.SizeChanged += this.TryRaiseLoadMoreRequested; + } + + /// + /// Determines if the scroll view has scrolled to the bottom, and if so triggers the . + /// This event will only cause the source to load at most once + /// + public void TryRaiseLoadMoreRequested() + { + if (this.ScrollView is null || !this.IsActive) + return; + + // Is only triggered when the view is not filled. + if ((this.ScrollView.ScrollableHeight is 0 && this.ScrollView.ScrollableWidth is 0) || + (this.ScrollView.ScrollableHeight > 0 && + this.ScrollView.ScrollableHeight - this.LoadingOffset < this.ScrollView.VerticalOffset) || + (this.ScrollView.ScrollableWidth > 0 && + this.ScrollView.ScrollableWidth - this.LoadingOffset < this.ScrollView.HorizontalOffset)) + Interaction.ExecuteActions(this.AssociatedObject, this.Actions, null); + } +} + +#endif diff --git a/components/Behaviors/tests/Behaviors.Tests.projitems b/components/Behaviors/tests/Behaviors.Tests.projitems new file mode 100644 index 000000000..67c5e0c76 --- /dev/null +++ b/components/Behaviors/tests/Behaviors.Tests.projitems @@ -0,0 +1,11 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 04B3CAE5-42E9-435E-B234-2C6BAC8D11C9 + + + BehaviorsExperiment.Tests + + \ No newline at end of file diff --git a/components/Behaviors/tests/Behaviors.Tests.shproj b/components/Behaviors/tests/Behaviors.Tests.shproj new file mode 100644 index 000000000..f43924891 --- /dev/null +++ b/components/Behaviors/tests/Behaviors.Tests.shproj @@ -0,0 +1,13 @@ + + + + 04B3CAE5-42E9-435E-B234-2C6BAC8D11C9 + 14.0 + + + + + + + +