351 lines
12 KiB
C#
351 lines
12 KiB
C#
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();
|
||
} |