using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Media.Imaging; using System.Windows.Threading; namespace XFEToolBox.WpfCore.Controls; /// /// 支持自动播放、悬停暂停、键盘操作与淡入淡出过渡的轮播控件。 /// public partial class Carousel : UserControl, INotifyPropertyChanged { private readonly DispatcherTimer timer; private readonly HashSet subscribedItems = []; private int currentIndex = -1; private CarouselImageItem? currentItem; #region Dependency properties public ObservableCollection ImageList { get => (ObservableCollection)GetValue(ImageListProperty); set => SetValue(ImageListProperty, value); } public static readonly DependencyProperty ImageListProperty = DependencyProperty.Register( nameof(ImageList), typeof(ObservableCollection), typeof(Carousel), new PropertyMetadata(null, OnImageListChanged)); public ImageSource? CurrentImageSource { get => (ImageSource?)GetValue(CurrentImageSourceProperty); private set => SetValue(CurrentImageSourcePropertyKey, value); } private static readonly DependencyPropertyKey CurrentImageSourcePropertyKey = DependencyProperty.RegisterReadOnly( nameof(CurrentImageSource), typeof(ImageSource), typeof(Carousel), new PropertyMetadata(null)); public static readonly DependencyProperty CurrentImageSourceProperty = CurrentImageSourcePropertyKey.DependencyProperty; public string CurrentTitle { get => (string)GetValue(CurrentTitleProperty); private set => SetValue(CurrentTitlePropertyKey, value); } private static readonly DependencyPropertyKey CurrentTitlePropertyKey = DependencyProperty.RegisterReadOnly( nameof(CurrentTitle), typeof(string), typeof(Carousel), new PropertyMetadata(string.Empty)); public static readonly DependencyProperty CurrentTitleProperty = CurrentTitlePropertyKey.DependencyProperty; public string CurrentBadge { get => (string)GetValue(CurrentBadgeProperty); private set => SetValue(CurrentBadgePropertyKey, value); } private static readonly DependencyPropertyKey CurrentBadgePropertyKey = DependencyProperty.RegisterReadOnly( nameof(CurrentBadge), typeof(string), typeof(Carousel), new PropertyMetadata(string.Empty)); public static readonly DependencyProperty CurrentBadgeProperty = CurrentBadgePropertyKey.DependencyProperty; public bool AutoPlay { get => (bool)GetValue(AutoPlayProperty); set => SetValue(AutoPlayProperty, value); } public static readonly DependencyProperty AutoPlayProperty = DependencyProperty.Register( nameof(AutoPlay), typeof(bool), typeof(Carousel), new PropertyMetadata(true, OnPlaybackPropertyChanged)); public TimeSpan Interval { get => (TimeSpan)GetValue(IntervalProperty); set => SetValue(IntervalProperty, value); } public static readonly DependencyProperty IntervalProperty = DependencyProperty.Register( nameof(Interval), typeof(TimeSpan), typeof(Carousel), new PropertyMetadata(TimeSpan.FromSeconds(5), OnPlaybackPropertyChanged, CoerceInterval)); public bool IsLoading { get => (bool)GetValue(IsLoadingProperty); set => SetValue(IsLoadingProperty, value); } public static readonly DependencyProperty IsLoadingProperty = DependencyProperty.Register( nameof(IsLoading), typeof(bool), typeof(Carousel), new PropertyMetadata(false, OnPlaybackPropertyChanged)); public string StatusMessage { get => (string)GetValue(StatusMessageProperty); set => SetValue(StatusMessageProperty, value); } public static readonly DependencyProperty StatusMessageProperty = DependencyProperty.Register( nameof(StatusMessage), typeof(string), typeof(Carousel), new PropertyMetadata("请稍后重试")); public bool CanRetry { get => (bool)GetValue(CanRetryProperty); set => SetValue(CanRetryProperty, value); } public static readonly DependencyProperty CanRetryProperty = DependencyProperty.Register( nameof(CanRetry), typeof(bool), typeof(Carousel), new PropertyMetadata(false)); #endregion public Carousel() { InitializeComponent(); SetCurrentValue(ImageListProperty, new ObservableCollection()); timer = new DispatcherTimer(DispatcherPriority.Background) { Interval = Interval }; timer.Tick += Timer_Tick; Loaded += Carousel_Loaded; Unloaded += Carousel_Unloaded; IsVisibleChanged += Carousel_IsVisibleChanged; MouseEnter += (_, _) => timer.Stop(); MouseLeave += (_, _) => RestartTimer(); GotKeyboardFocus += (_, _) => timer.Stop(); LostKeyboardFocus += (_, _) => Dispatcher.BeginInvoke(RestartTimer, DispatcherPriority.Background); } public bool HasItems => ImageList?.Count > 0; public bool HasMultipleItems => ImageList?.Count > 1; public string CurrentPosition => HasItems ? $"{currentIndex + 1:00} / {ImageList.Count:00}" : string.Empty; public event EventHandler? RetryRequested; public event PropertyChangedEventHandler? PropertyChanged; private static object CoerceInterval(DependencyObject d, object baseValue) { var interval = (TimeSpan)baseValue; return interval < TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : interval; } private static void OnPlaybackPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not Carousel carousel || carousel.timer is null) return; carousel.timer.Interval = carousel.Interval; carousel.RestartTimer(); } private static void OnImageListChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not Carousel carousel) return; if (e.OldValue is ObservableCollection oldItems) oldItems.CollectionChanged -= carousel.ImageList_CollectionChanged; carousel.UnsubscribeAllItems(); if (e.NewValue is ObservableCollection newItems) { newItems.CollectionChanged += carousel.ImageList_CollectionChanged; carousel.SubscribeItems(newItems); } carousel.currentIndex = -1; carousel.currentItem = null; carousel.UpdateImage(skipTransition: true); } private void Carousel_Loaded(object sender, RoutedEventArgs e) { UpdateImage(skipTransition: true); RestartTimer(); } private void Carousel_Unloaded(object sender, RoutedEventArgs e) => timer.Stop(); private void Carousel_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) => RestartTimer(); private void ImageViewport_SizeChanged(object sender, SizeChangedEventArgs e) { const double cornerRadius = 15; ImageViewport.Clip = new RectangleGeometry( new Rect(0, 0, e.NewSize.Width, e.NewSize.Height), cornerRadius, cornerRadius); } private void Timer_Tick(object? sender, EventArgs e) { if (!CanAdvanceAutomatically()) return; NavigateTo(currentIndex + 1, restartTimer: false); } private bool CanAdvanceAutomatically() => IsLoaded && IsVisible && IsEnabled && AutoPlay && !IsLoading && HasMultipleItems && !IsMouseOver && !IsKeyboardFocusWithin; private void RestartTimer() { if (timer is null) return; timer.Stop(); if (IsLoaded && IsVisible && AutoPlay && !IsLoading && HasMultipleItems && !IsMouseOver && !IsKeyboardFocusWithin) timer.Start(); } private void ImageList_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Reset) { UnsubscribeAllItems(); SubscribeItems(ImageList); } else { if (e.OldItems is not null) { foreach (CarouselImageItem item in e.OldItems) UnsubscribeItem(item); } if (e.NewItems is not null) { foreach (CarouselImageItem item in e.NewItems) SubscribeItem(item); } } var previousItem = currentItem; if (ImageList.Count == 0) currentIndex = -1; else if (previousItem is not null && ImageList.Contains(previousItem)) currentIndex = ImageList.IndexOf(previousItem); else currentIndex = Math.Clamp(currentIndex, 0, ImageList.Count - 1); UpdateImage(skipTransition: ReferenceEquals(previousItem, currentItem)); RestartTimer(); } private void CarouselItem_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (!ReferenceEquals(sender, currentItem)) return; if (e.PropertyName is nameof(CarouselImageItem.Image) or nameof(CarouselImageItem.Title) or nameof(CarouselImageItem.Badge) or nameof(CarouselImageItem.Action)) UpdateImage(skipTransition: true); } private void SubscribeItems(IEnumerable items) { foreach (var item in items) SubscribeItem(item); } private void SubscribeItem(CarouselImageItem item) { if (subscribedItems.Add(item)) item.PropertyChanged += CarouselItem_PropertyChanged; } private void UnsubscribeItem(CarouselImageItem item) { if (subscribedItems.Remove(item)) item.PropertyChanged -= CarouselItem_PropertyChanged; } private void UnsubscribeAllItems() { foreach (var item in subscribedItems) item.PropertyChanged -= CarouselItem_PropertyChanged; subscribedItems.Clear(); } private void UpdateImage(bool skipTransition = false) { if (!HasItems) { currentIndex = -1; currentItem = null; CurrentImageSource = null; CurrentTitle = string.Empty; CurrentBadge = string.Empty; ImageFront.Source = null; ImageBack.Source = null; NotifyStateChanged(); return; } currentIndex = Math.Clamp(currentIndex, 0, ImageList.Count - 1); var nextItem = ImageList[currentIndex]; var itemChanged = !ReferenceEquals(currentItem, nextItem); foreach (var item in ImageList) item.IsSelected = ReferenceEquals(item, nextItem); CurrentImageSource = nextItem.Image; CurrentTitle = nextItem.Title; CurrentBadge = nextItem.Badge; currentItem = nextItem; if (!itemChanged || skipTransition) ShowImmediately(nextItem.Image); else BeginCrossFade(nextItem.Image); NotifyStateChanged(); } private void ShowImmediately(ImageSource? image) { ImageFront.BeginAnimation(OpacityProperty, null); ImageBack.BeginAnimation(OpacityProperty, null); ImageFront.Source = image; ImageFront.Opacity = 1; ImageBack.Source = null; ImageBack.Opacity = 0; } private void BeginCrossFade(ImageSource? image) { ImageFront.BeginAnimation(OpacityProperty, null); ImageBack.BeginAnimation(OpacityProperty, null); ImageBack.Source = ImageFront.Source; ImageBack.Opacity = ImageBack.Source is null ? 0 : 1; ImageFront.Source = image; ImageFront.Opacity = 0; var easing = new CubicEase { EasingMode = EasingMode.EaseOut }; var duration = TimeSpan.FromMilliseconds(360); var fadeIn = new DoubleAnimation(0, 1, duration) { EasingFunction = easing }; var fadeOut = new DoubleAnimation(1, 0, duration) { EasingFunction = easing }; fadeOut.Completed += (_, _) => { ImageBack.Source = null; ImageBack.Opacity = 0; }; ImageBack.BeginAnimation(OpacityProperty, fadeOut, HandoffBehavior.SnapshotAndReplace); ImageFront.BeginAnimation(OpacityProperty, fadeIn, HandoffBehavior.SnapshotAndReplace); } private void NavigateTo(int index, bool restartTimer = true) { if (!HasItems) return; var normalizedIndex = (index % ImageList.Count + ImageList.Count) % ImageList.Count; if (normalizedIndex != currentIndex) { currentIndex = normalizedIndex; UpdateImage(); } if (restartTimer) RestartTimer(); } private void PrevButton_Click(object sender, RoutedEventArgs e) => NavigateTo(currentIndex - 1); private void NextButton_Click(object sender, RoutedEventArgs e) => NavigateTo(currentIndex + 1); private void Indicator_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement { DataContext: CarouselImageItem item }) { var index = ImageList.IndexOf(item); if (index >= 0) NavigateTo(index); } e.Handled = true; } private void Carousel_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Left) { NavigateTo(currentIndex - 1); e.Handled = true; } else if (e.Key == Key.Right) { NavigateTo(currentIndex + 1); e.Handled = true; } else if (e.Key is Key.Enter or Key.Space) { InvokeCurrentAction(); e.Handled = true; } } private void Image_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { InvokeCurrentAction(); e.Handled = true; } private void InvokeCurrentAction() { if (currentItem?.Action is null) return; try { currentItem.Action.Invoke(); } catch { // 外部链接打开失败时不应影响轮播控件继续工作。 } } private void RetryButton_Click(object sender, RoutedEventArgs e) { RetryRequested?.Invoke(this, EventArgs.Empty); e.Handled = true; } /// /// 一次性替换轮播内容,避免逐项添加造成重复布局和动画。 /// public void SetItems(IEnumerable items) { ArgumentNullException.ThrowIfNull(items); SetCurrentValue(ImageListProperty, new ObservableCollection(items)); } public void AddItem(ImageSource image, string title, Action? action = null) { ArgumentNullException.ThrowIfNull(image); if (image is BitmapSource bitmapSource && bitmapSource.CanFreeze) { try { bitmapSource.Freeze(); } catch (InvalidOperationException) { // 正在异步下载的 BitmapImage 不能冻结,可继续由 WPF 管理。 } } ImageList.Add(new CarouselImageItem { Image = image, Title = title, Action = action }); } private void NotifyStateChanged() { OnPropertyChanged(nameof(HasItems)); OnPropertyChanged(nameof(HasMultipleItems)); OnPropertyChanged(nameof(CurrentPosition)); } private void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }