Files
Shrlalgo.RvKits/Melskin/Controls/Anchor.xaml.cs

351 lines
12 KiB
C#
Raw Normal View History

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