Files
ShrlAlgoToolkit/Melskin/Controls/Anchor.xaml.cs
2026-02-12 21:29:00 +08:00

351 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 锚点
/// </summary>
[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<string, UIElement?> anchorElements = new();
// 标志位:是否正在通过代码设置选中项(防止触发滚动)
private bool isInternalSelectionChange;
// 标志位:是否正在执行点击导航导致的自动滚动(防止滚动时频繁更新高亮)
private bool isAutoScrolling;
// 动画中介器(复用实例以便取消动画)
private readonly ScrollViewerOffsetMediator mediator = new();
#endregion
static Anchor()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(Anchor), new FrameworkPropertyMetadata(typeof(Anchor)));
}
/// <inheritdoc />
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<UIElement>()
.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<string>().ToList() ?? new List<string>();
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();
}
/// <summary>
/// 用户点击列表项触发
/// </summary>
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
}
/// <summary>
/// 辅助类保持不变,但建议确保它能复用
/// </summary>
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();
}