using System; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Threading; using Melskin.Assists; using Melskin.Extensions; namespace Melskin.Controls; /// /// 锚点 /// [TemplatePart(Name = "PART_AnchorList", Type = typeof(ListBox))] [TemplatePart(Name = "PART_ContentScroller", Type = typeof(ScrollViewer))] public class Anchor : ContentControl { #region 私有字段 private ListBox? anchorList; private ScrollViewer? contentScroller; // 扫描防抖 private DispatcherTimer? scanDebounceTimer; // 缓存锚点映射 private readonly Dictionary anchorElements = new(); // 标志位:是否正在通过代码设置选中项(防止触发滚动) private bool isInternalSelectionChange; // 标志位:是否正在执行点击导航导致的自动滚动(防止滚动时频繁更新高亮) private bool isAutoScrolling; // 动画中介器(复用实例以便取消动画) private readonly ScrollViewerOffsetMediator mediator = new(); #endregion static Anchor() { DefaultStyleKeyProperty.OverrideMetadata(typeof(Anchor), new FrameworkPropertyMetadata(typeof(Anchor))); } /// public override void OnApplyTemplate() { base.OnApplyTemplate(); UnbindEvents(); scanDebounceTimer?.Stop(); anchorList = GetTemplateChild("PART_AnchorList") as ListBox; contentScroller = GetTemplateChild("PART_ContentScroller") as ScrollViewer; if (anchorList != null && contentScroller != null) { // 初始化中介器关联 mediator.ScrollViewer = contentScroller; BindEvents(); InitializeScanTimer(); // 延迟一点以等待布局完成 contentScroller.Loaded += RequestScan; } } private void UnbindEvents() { if (anchorList != null) anchorList.SelectionChanged -= AnchorList_SelectionChanged; if (contentScroller != null) { contentScroller.ScrollChanged -= ContentScroller_ScrollChanged; contentScroller.SizeChanged -= RequestScan; if (contentScroller.Content is FrameworkElement fe) fe.LayoutUpdated -= RequestScan; } } private void BindEvents() { if (anchorList != null) anchorList.SelectionChanged += AnchorList_SelectionChanged; if (contentScroller == null) return; contentScroller.ScrollChanged += ContentScroller_ScrollChanged; contentScroller.SizeChanged += RequestScan; if (contentScroller.Content is FrameworkElement fe) fe.LayoutUpdated += RequestScan; } private void InitializeScanTimer() { scanDebounceTimer = new DispatcherTimer(DispatcherPriority.ContextIdle, Dispatcher) { Interval = TimeSpan.FromMilliseconds(150) }; scanDebounceTimer.Tick += (_, _) => { scanDebounceTimer.Stop(); ScanAnchors(); }; } #region 逻辑处理 private void RequestScan(object? sender, EventArgs e) { scanDebounceTimer?.Stop(); scanDebounceTimer?.Start(); } private void ScanAnchors() { if (contentScroller == null || anchorList == null) return; anchorElements.Clear(); // 查找所有锚点 // 注意:这里假设 Melskin.Extensions 的 FindVisualChildren 工作正常 var anchorUiElements = contentScroller.FindVisualChildren() .Where(el => VisualTreeHelper.GetParent(el) != null && !string.IsNullOrEmpty(ControlAssist.GetAnchorHeaderText(el))) .ToList(); foreach (var element in anchorUiElements) { anchorElements[ControlAssist.GetAnchorHeaderText(element)] = element; } var newNames = anchorElements.Keys.ToList(); var currentNames = anchorList.ItemsSource?.Cast().ToList() ?? new List(); if (!currentNames.SequenceEqual(newNames)) { // 保持当前选中的项(如果在列表中还存在) var oldSelection = anchorList.SelectedItem; isInternalSelectionChange = true; anchorList.ItemsSource = newNames; if (oldSelection != null && newNames.Contains(oldSelection)) { anchorList.SelectedItem = oldSelection; } isInternalSelectionChange = false; } // 初始扫描后更新一次高亮 UpdateHighlight(); } /// /// 用户点击列表项触发 /// private void AnchorList_SelectionChanged(object sender, SelectionChangedEventArgs e) { // 如果是代码设置的选中(如滚动触发的高亮更新),则不执行滚动逻辑,防止死循环 if (isInternalSelectionChange) return; if (e.AddedItems.Count == 0) return; if (e.AddedItems[0] is string selectedAnchorName && anchorElements.TryGetValue(selectedAnchorName, out var targetElement)) { // 标记为自动滚动,暂停高亮更新 isAutoScrolling = true; ScrollToElement(targetElement); } } private void ScrollToElement(UIElement? targetElement) { if (contentScroller == null || targetElement == null) return; try { // 获取目标相对位置 var transform = targetElement.TransformToVisual(contentScroller); var position = transform.Transform(new Point(0, 0)); // 目标 offset = 当前 offset + 相对位置 var currentOffset = contentScroller.VerticalOffset; var targetOffset = currentOffset + position.Y; // 边界检查 if (targetOffset < 0) targetOffset = 0; if (targetOffset > contentScroller.ScrollableHeight) targetOffset = contentScroller.ScrollableHeight; // 如果距离很短,直接跳过去,不动画(可选优化) if (Math.Abs(targetOffset - currentOffset) < 2.0) { contentScroller.ScrollToVerticalOffset(targetOffset); isAutoScrolling = false; return; } // 停止旧动画 mediator.BeginAnimation(ScrollViewerOffsetMediator.VerticalOffsetProperty, null); // 确保 Mediator 的起始值与当前 ScrollViewer 一致 // 这一步很重要,否则动画可能会从上一次动画结束的位置开始跳变 mediator.VerticalOffset = currentOffset; var animation = new DoubleAnimation( currentOffset, targetOffset, new Duration(TimeSpan.FromMilliseconds(400))) // 建议400-500ms { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }; animation.Completed += (_, _) => { isAutoScrolling = false; // 动画结束后强制校准一次高亮,防止浮点误差导致的选中项错误 UpdateHighlight(); }; mediator.BeginAnimation(ScrollViewerOffsetMediator.VerticalOffsetProperty, animation); } catch (Exception) { isAutoScrolling = false; } } private void ContentScroller_ScrollChanged(object sender, ScrollChangedEventArgs e) { // 如果是点击导航导致的滚动,不要更新高亮,以免列表项乱跳 if (isAutoScrolling) return; // 只有垂直滚动才处理 if (Math.Abs(e.VerticalChange) > 0) { UpdateHighlight(); } } private void UpdateHighlight() { if (anchorElements.Count == 0 || anchorList == null || contentScroller == null) return; // 获取 Viewport 的信息 //double viewportTop = 0; //double viewportHeight = contentScroller.ViewportHeight; double scrollOffset = contentScroller.VerticalOffset; double scrollHeight = contentScroller.ScrollableHeight; string? bestMatch = null; // 策略1:如果已经滚动到底部,直接选中最后一个 // 使用一个小的容差 (1.0) if (scrollOffset >= scrollHeight - 1.0 && scrollHeight > 0) { bestMatch = anchorElements.Keys.LastOrDefault(); } else { // 策略2:寻找当前视口中最靠上的元素 // 也就是:Top 值 <= 某个阈值(比如 ViewportHeight 的 1/3 或一个固定像素值)的最后一个元素 // 我们寻找所有 "Top < 20px" 的元素,取其中位置最大的一个(最靠近顶部的) // 解释:如果元素A的Top是 -100,元素B的Top是 -10,元素C的Top是 50。 // 我们认为A和B都已经滚过去了,B是当前“激活”的块。 double threshold = 20.0; // 20像素的宽容度,解决刚好停在边缘的问题 foreach (var kvp in anchorElements) { var element = kvp.Value; if (element == null || !element.IsVisible) continue; try { // 获取元素相对于 ScrollViewer 可视区域左上角的坐标 var transform = element.TransformToVisual(contentScroller); var position = transform.Transform(new Point(0, 0)); var top = position.Y; // 核心逻辑: // 只要元素的 Top 小于等于 阈值,它就是候选者。 // 因为字典通常是按顺序扫描生成的,后面的元素会覆盖前面的 bestMatch if (top <= threshold) { bestMatch = kvp.Key; } else { // 因为是顺序排列,一旦遇到 Top > threshold 的,说明后面的都在视口下面了,可以直接停止 // 除非你的布局不是垂直线性的,否则这样效率最高 break; } } catch { // 忽略异常 } } // 如果没有任何元素满足条件(比如第一个元素都在下面),则选中第一个 if (bestMatch == null) { bestMatch = anchorElements.Keys.FirstOrDefault(); } } // 更新 ListBox 选中项 if (bestMatch != null && (string?)anchorList.SelectedItem != bestMatch) { // 标记为内部变更,防止触发 AnchorList_SelectionChanged 里的 ScrollToElement isInternalSelectionChange = true; anchorList.SelectedItem = bestMatch; anchorList.ScrollIntoView(bestMatch); // 确保左侧列表也跟随滚动 isInternalSelectionChange = false; } } #endregion } /// /// 辅助类保持不变,但建议确保它能复用 /// internal class ScrollViewerOffsetMediator : Animatable { public ScrollViewer? ScrollViewer { get => (ScrollViewer)GetValue(ScrollViewerProperty); set => SetValue(ScrollViewerProperty, value); } public static readonly DependencyProperty ScrollViewerProperty = DependencyProperty.Register(nameof(ScrollViewer), typeof(ScrollViewer), typeof(ScrollViewerOffsetMediator), new PropertyMetadata(null)); public double VerticalOffset { get => (double)GetValue(VerticalOffsetProperty); set => SetValue(VerticalOffsetProperty, value); } public static readonly DependencyProperty VerticalOffsetProperty = DependencyProperty.Register(nameof(VerticalOffset), typeof(double), typeof(ScrollViewerOffsetMediator), new PropertyMetadata(0.0, OnVerticalOffsetChanged)); private static void OnVerticalOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is ScrollViewerOffsetMediator mediator && mediator.ScrollViewer != null) { mediator.ScrollViewer.ScrollToVerticalOffset((double)e.NewValue); } } protected override Freezable CreateInstanceCore() => new ScrollViewerOffsetMediator(); }