427 lines
14 KiB
C#
427 lines
14 KiB
C#
using System.Windows.Media.Animation;
|
|
using System.Windows.Threading;
|
|
|
|
using NeoUI.Assists;
|
|
|
|
namespace NeoUI.Controls;
|
|
|
|
/// <summary>
|
|
/// 锚点导航控件,提供页面内锚点跳转和高亮功能
|
|
/// 支持自动扫描带有 AnchorAssist.Header 属性的元素,并生成导航列表
|
|
/// </summary>
|
|
[TemplatePart(Name = "PART_AnchorList", Type = typeof(ListBox))]
|
|
[TemplatePart(Name = "PART_ContentScroller", Type = typeof(ScrollViewer))]
|
|
public class Anchor : ContentControl
|
|
{
|
|
/// <summary>
|
|
/// 滚动状态枚举
|
|
/// </summary>
|
|
private enum ScrollState
|
|
{
|
|
/// <summary>空闲状态,可以响应用户交互</summary>
|
|
Idle,
|
|
/// <summary>动画滚动中,阻止手动滚动的高亮更新</summary>
|
|
Animating,
|
|
/// <summary>手动滚动中</summary>
|
|
ManualScrolling
|
|
}
|
|
|
|
#region 私有字段
|
|
|
|
/// <summary>锚点列表控件</summary>
|
|
private ListBox? anchorList;
|
|
|
|
/// <summary>内容滚动视图</summary>
|
|
private ScrollViewer? contentScroller;
|
|
|
|
/// <summary>当前滚动状态</summary>
|
|
private ScrollState currentState = ScrollState.Idle;
|
|
|
|
/// <summary>扫描防抖计时器,避免频繁扫描锚点</summary>
|
|
private DispatcherTimer? scanDebounceTimer;
|
|
|
|
/// <summary>锚点名称到UI元素的映射字典</summary>
|
|
private readonly Dictionary<string, UIElement?> anchorElements = new();
|
|
|
|
#endregion
|
|
|
|
#region 构造函数和模板应用
|
|
|
|
static Anchor()
|
|
{
|
|
DefaultStyleKeyProperty.OverrideMetadata(typeof(Anchor), new FrameworkPropertyMetadata(typeof(Anchor)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 应用控件模板,初始化内部控件引用和事件绑定
|
|
/// </summary>
|
|
public override void OnApplyTemplate()
|
|
{
|
|
base.OnApplyTemplate();
|
|
|
|
// 清理旧的事件绑定
|
|
UnbindEvents();
|
|
|
|
// 停止旧的计时器
|
|
scanDebounceTimer?.Stop();
|
|
|
|
// 获取模板中的控件
|
|
anchorList = (ListBox)GetTemplateChild("PART_AnchorList");
|
|
contentScroller = (ScrollViewer)GetTemplateChild("PART_ContentScroller");
|
|
|
|
// 绑定新的事件和初始化
|
|
if (anchorList != null && contentScroller != null)
|
|
{
|
|
BindEvents();
|
|
InitializeScanTimer();
|
|
|
|
// 延迟扫描,确保控件完全加载
|
|
contentScroller.Loaded += RequestScan;
|
|
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 解绑事件处理器
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 绑定事件处理器
|
|
/// </summary>
|
|
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 fe2)
|
|
fe2.LayoutUpdated += RequestScan;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 初始化扫描防抖计时器
|
|
/// </summary>
|
|
private void InitializeScanTimer()
|
|
{
|
|
scanDebounceTimer = new DispatcherTimer(DispatcherPriority.ContextIdle, Dispatcher)
|
|
{
|
|
Interval = TimeSpan.FromMilliseconds(100) // 150ms 防抖延迟
|
|
};
|
|
scanDebounceTimer.Tick += (_, _) =>
|
|
{
|
|
scanDebounceTimer.Stop();
|
|
ScanAnchors();
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 锚点扫描
|
|
|
|
/// <summary>
|
|
/// 请求扫描锚点(防抖处理)
|
|
/// </summary>
|
|
private void RequestScan(object sender, EventArgs e)
|
|
{
|
|
scanDebounceTimer?.Stop();
|
|
scanDebounceTimer?.Start();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 扫描内容区域中的所有锚点元素
|
|
/// 查找带有 AnchorAssist.Header 属性的UI元素并更新锚点列表
|
|
/// </summary>
|
|
private void ScanAnchors()
|
|
{
|
|
if (contentScroller == null || anchorList == null) return;
|
|
|
|
// 清空现有锚点映射
|
|
anchorElements.Clear();
|
|
|
|
// 查找所有带有锚点标识的UI元素
|
|
List<UIElement?> anchorUiElements = FindVisualChildren<UIElement>(contentScroller)
|
|
.Where(el => VisualTreeHelper.GetParent(el) != null &&
|
|
!string.IsNullOrEmpty(AnchorAssist.GetHeader(el)))
|
|
.ToList();
|
|
|
|
// 构建锚点名称到元素的映射
|
|
foreach (var element in anchorUiElements)
|
|
{
|
|
anchorElements[AnchorAssist.GetHeader(element)] = element;
|
|
}
|
|
|
|
// 更新锚点列表数据源(仅在内容变化时)
|
|
var newNames = anchorElements.Keys.ToList();
|
|
var currentNames = anchorList.ItemsSource?.Cast<string>().ToList() ?? new List<string>();
|
|
|
|
if (!currentNames.SequenceEqual(newNames))
|
|
{
|
|
anchorList.ItemsSource = newNames;
|
|
}
|
|
|
|
// 更新当前高亮项
|
|
UpdateHighlight();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 事件处理
|
|
|
|
/// <summary>
|
|
/// 锚点列表选择变化事件处理
|
|
/// 执行平滑滚动到选中的锚点位置
|
|
/// </summary>
|
|
private void AnchorList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
// 仅在空闲状态且有选中项时处理
|
|
if (currentState != ScrollState.Idle || e.AddedItems.Count == 0) return;
|
|
|
|
var selectedAnchorName = e.AddedItems[0] as string;
|
|
if (selectedAnchorName != null && anchorElements.TryGetValue(selectedAnchorName, out var targetElement))
|
|
{
|
|
ScrollToElement(targetElement);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 滚动到指定元素位置(带动画效果)
|
|
/// </summary>
|
|
/// <param name="targetElement">目标元素</param>
|
|
private void ScrollToElement(UIElement targetElement)
|
|
{
|
|
try
|
|
{
|
|
// 计算目标元素相对于滚动视图的位置
|
|
if (contentScroller == null) return;
|
|
var transform = targetElement.TransformToVisual(contentScroller);
|
|
var position = transform.Transform(new Point(0, 0));
|
|
var targetOffset = contentScroller.VerticalOffset + position.Y;
|
|
|
|
// 使用中介器执行动画滚动
|
|
var mediator = new ScrollViewerOffsetMediator { ScrollViewer = contentScroller };
|
|
|
|
// 取消之前的动画
|
|
mediator.BeginAnimation(ScrollViewerOffsetMediator.VerticalOffsetProperty, null);
|
|
|
|
// 创建平滑滚动动画
|
|
var animation = new DoubleAnimation(
|
|
contentScroller.VerticalOffset,
|
|
targetOffset,
|
|
TimeSpan.FromMilliseconds(400))
|
|
{
|
|
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
|
};
|
|
|
|
// 设置动画状态和完成回调
|
|
currentState = ScrollState.Animating;
|
|
animation.Completed += (_, _) =>
|
|
{
|
|
currentState = ScrollState.Idle;
|
|
UpdateHighlight(); // 动画完成后更新高亮
|
|
};
|
|
|
|
mediator.BeginAnimation(ScrollViewerOffsetMediator.VerticalOffsetProperty, animation);
|
|
}
|
|
catch
|
|
{
|
|
// 防止 TransformToVisual 在元素未完全渲染时抛出异常
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 内容滚动变化事件处理
|
|
/// 在手动滚动时更新锚点高亮
|
|
/// </summary>
|
|
private void ContentScroller_ScrollChanged(object sender, ScrollChangedEventArgs e)
|
|
{
|
|
// 动画滚动期间忽略滚动事件
|
|
if (currentState == ScrollState.Animating) return;
|
|
|
|
// 仅处理垂直滚动
|
|
if (e.VerticalChange != 0)
|
|
{
|
|
currentState = ScrollState.ManualScrolling;
|
|
UpdateHighlight();
|
|
currentState = ScrollState.Idle;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 高亮更新
|
|
|
|
/// <summary>
|
|
/// 更新锚点列表中的高亮项
|
|
/// 根据当前滚动位置计算最合适的锚点并设为选中状态
|
|
/// </summary>
|
|
private void UpdateHighlight()
|
|
{
|
|
if (anchorElements.Count == 0 || anchorList == null || contentScroller == null) return;
|
|
|
|
UIElement? bestMatchElement = null;
|
|
|
|
// 规则 #1: 如果滚动到底部,高亮最后一个锚点
|
|
if (contentScroller.VerticalOffset >= contentScroller.ScrollableHeight - 1.0 &&
|
|
contentScroller.ScrollableHeight > 0)
|
|
{
|
|
bestMatchElement = anchorElements.LastOrDefault().Value;
|
|
}
|
|
else
|
|
{
|
|
// 规则 #2: 找到最接近视窗顶部且仍然可见的锚点
|
|
var bestMatchDistance = double.NegativeInfinity;
|
|
|
|
foreach (var element in anchorElements.Values)
|
|
{
|
|
if (element != null && VisualTreeHelper.GetParent(element) == null) continue;
|
|
|
|
try
|
|
{
|
|
var transform = element?.TransformToVisual(contentScroller);
|
|
if (transform != null)
|
|
{
|
|
var position = transform.Transform(new Point(0, 0));
|
|
|
|
// 选择最接近视窗顶部但仍在视窗内的元素
|
|
if (position.Y <= 0.5 && position.Y > bestMatchDistance)
|
|
{
|
|
bestMatchDistance = position.Y;
|
|
bestMatchElement = element;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// 安全容错处理
|
|
}
|
|
}
|
|
}
|
|
|
|
// 回退策略:如果没有找到合适的元素,选择第一个
|
|
bestMatchElement ??= anchorElements.FirstOrDefault().Value;
|
|
|
|
// 更新列表选中项(避免重复设置)
|
|
if (bestMatchElement == null) return;
|
|
var anchorName = AnchorAssist.GetHeader(bestMatchElement);
|
|
if ((string)anchorList.SelectedItem == anchorName) return;
|
|
// 临时设置为动画状态,防止触发选择变化事件
|
|
currentState = ScrollState.Animating;
|
|
anchorList.SelectedItem = anchorName;
|
|
currentState = ScrollState.Idle;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 工具方法
|
|
|
|
/// <summary>
|
|
/// 递归查找可视化树中指定类型的所有子元素
|
|
/// </summary>
|
|
/// <typeparam name="T">要查找的元素类型</typeparam>
|
|
/// <param name="depObj">起始依赖对象</param>
|
|
/// <returns>找到的所有匹配元素</returns>
|
|
private static IEnumerable<T> FindVisualChildren<T>(DependencyObject? depObj) where T : DependencyObject
|
|
{
|
|
if (depObj == null) yield break;
|
|
|
|
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(depObj); i++)
|
|
{
|
|
var child = VisualTreeHelper.GetChild(depObj, i);
|
|
if (child is T t)
|
|
yield return t;
|
|
|
|
// 递归查找子元素
|
|
foreach (var descendant in FindVisualChildren<T>(child))
|
|
yield return descendant;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
/// <summary>
|
|
/// ScrollViewer 偏移量动画中介器
|
|
/// 由于 ScrollViewer.VerticalOffset 不是依赖属性,无法直接动画化
|
|
/// 此类作为中介,将动画值传递给 ScrollViewer.ScrollToVerticalOffset 方法
|
|
/// </summary>
|
|
internal class ScrollViewerOffsetMediator : Animatable
|
|
{
|
|
#region 依赖属性
|
|
|
|
/// <summary>
|
|
/// 关联的 ScrollViewer 控件
|
|
/// </summary>
|
|
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));
|
|
|
|
/// <summary>
|
|
/// 垂直偏移量(可动画化的依赖属性)
|
|
/// </summary>
|
|
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));
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// 垂直偏移量属性变化回调
|
|
/// 将动画值同步到 ScrollViewer 的实际滚动位置
|
|
/// </summary>
|
|
private static void OnVerticalOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
|
{
|
|
if (d is not ScrollViewerOffsetMediator mediator) return;
|
|
if (mediator.ScrollViewer?.IsLoaded == true)
|
|
{
|
|
// ScrollViewer 已加载,直接滚动
|
|
mediator.ScrollViewer.ScrollToVerticalOffset((double)e.NewValue);
|
|
}
|
|
else
|
|
{
|
|
// ScrollViewer 未完全加载,延迟到加载完成后执行
|
|
mediator.ScrollViewer?.Dispatcher.BeginInvoke(() =>
|
|
{
|
|
mediator.ScrollViewer.ScrollToVerticalOffset((double)e.NewValue);
|
|
}, System.Windows.Threading.DispatcherPriority.Loaded);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 创建 Freezable 实例(动画系统要求)
|
|
/// </summary>
|
|
protected override Freezable CreateInstanceCore()
|
|
{
|
|
return new ScrollViewerOffsetMediator();
|
|
}
|
|
} |