自定义WPF ListWheelPanel实现IScrollInfo后滚动条滑块不移动问题
问题描述
我创建了一个可将子项环绕圆形排列的自定义WPF面板ListWheelPanel,目标是支持滚动操作,使所有子元素绕“3点钟”位置旋转。已实现IScrollInfo接口,点击滚动条或滚动滚轮时面板可正常滚动,但水平和垂直滚动条的滑块无法移动(滑块尺寸已根据视口与范围的比例正确调整)。
实现代码
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Media; namespace ListWheel { public class ListWheelPanel : Panel, IScrollInfo { private Size _MaxChildSize = new(0,0); private int _NumberOfChildrenOnHalfCircle; private Point _Origin = new(0,0); private int topRightPosition; private int bottomRightPosition; private readonly List<Point> _Positions = new(); public double Radius { get { return (double)GetValue(RadiusProperty); } set { SetValue(RadiusProperty, value); } } // Using a DependencyProperty as the backing store for Radius. This enables animation, styling, binding, etc... public static readonly DependencyProperty RadiusProperty = DependencyProperty.Register("Radius", typeof(double), typeof(ListWheelPanel), new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.AffectsMeasure)); public int PositionOffset { get { return (int)GetValue(PositionOffsetProperty); } set { SetValue(PositionOffsetProperty, value); } } // Using a DependencyProperty as the backing store for PositionOffset. This enables animation, styling, binding, etc... public static readonly DependencyProperty PositionOffsetProperty = DependencyProperty.Register("PositionOffset", typeof(int), typeof(ListWheelPanel), new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.AffectsArrange, OnPositionOffsetChanged, ConstrainPositionOffsetToRange)); private static void OnPositionOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ListWheelPanel listWheelPanel = (ListWheelPanel)d; listWheelPanel._ScrollViewer.InvalidateScrollInfo(); } private static object ConstrainPositionOffsetToRange(DependencyObject d, object baseValue) { ListWheelPanel listWheelPanel = (ListWheelPanel)d; return baseValue switch { int n when n > 0 => 0, int n when n <= 0 && n > 2 - listWheelPanel._Positions.Count => n, int n when n <= 2 - listWheelPanel._Positions.Count => 3 - listWheelPanel._Positions.Count, _ => (object)0, }; } private bool _CanHorizontallyScroll = true; public bool CanHorizontallyScroll { get => _CanHorizontallyScroll; set => _CanHorizontallyScroll = value; } private bool _CanVerticallyScroll = true; public bool CanVerticallyScroll { get => _CanVerticallyScroll; set => _CanVerticallyScroll = value; } private ScrollViewer _ScrollViewer; public ScrollViewer ScrollOwner { get => _ScrollViewer; set => _ScrollViewer = value; } // Extent is the total number of children. public double ExtentHeight => Children.Count; public double ExtentWidth => Children.Count; // Viewport is the number of visible children. private double _NumberOfVisibleChildren; public double ViewportHeight => _NumberOfVisibleChildren; public double ViewportWidth => _NumberOfVisibleChildren; // Offset is the position offset public double HorizontalOffset => PositionOffset; public double VerticalOffset => PositionOffset; // The ListWheelPanel arranges children around a circle. protected override Size MeasureOverride(Size availableSize) { . . SKIPPING SOME CODE THAT I DON'T THINK IS PERTINENT . // how many children fit in viewport? _NumberOfVisibleChildren = Math.Floor(viewportSize.Height / _MaxChildSize.Height); if (_ScrollViewer != null) _ScrollViewer.InvalidateScrollInfo(); return viewportSize; } private double GetXFromY(double y, bool leftSide) { double theta = Math.Acos(y / Radius); double x = Radius * Math.Sin(theta); if (leftSide) return -x; else return x; } protected override Size ArrangeOverride(Size finalSize) { for (int i = 0; i < Children.Count; i++) { UIElement child = Children[i]; int currentPosition = (i + PositionOffset + _Positions.Count) % _Positions.Count; Rect newRect; if (currentPosition > topRightPosition && currentPosition < bottomRightPosition) newRect = new Rect(_Positions[currentPosition].X - child.DesiredSize.Width, _Positions[currentPosition].Y, child.DesiredSize.Width, child.DesiredSize.Height); else newRect = new Rect(_Positions[currentPosition].X, _Positions[currentPosition].Y, child.DesiredSize.Width, child.DesiredSize.Height); child.Arrange(newRect); } return finalSize; } protected override void OnRender(DrawingContext dc) { dc.DrawEllipse(Brushes.Transparent, new Pen(Brushes.Aquamarine, 1), _Origin, Radius, Radius); base.OnRender(dc); } public void LineDown() { SetVerticalOffset(1); } public void LineLeft() { SetHorizontalOffset(-1); } public void LineRight() { SetHorizontalOffset(1); } public Rect MakeVisible(Visual visual, Rect rectangle) { return rectangle; } public void MouseWheelDown() { SetVerticalOffset(1); } public void MouseWheelLeft() { SetHorizontalOffset(-1); } public void MouseWheelRight() { SetHorizontalOffset(1); } public void MouseWheelUp() { SetVerticalOffset(-1); } public void PageDown() { SetVerticalOffset(_NumberOfVisibleChildren - 1); } public void PageLeft() { SetHorizontalOffset(1 - _NumberOfVisibleChildren); } public void PageRight() { SetHorizontalOffset(_NumberOfVisibleChildren - 1); } public void PageUp() { SetVerticalOffset(1 - _NumberOfVisibleChildren); } public void SetHorizontalOffset(double offset) { PositionOffset -= (int)offset; } public void SetVerticalOffset(double offset) { PositionOffset -= (int)offset; } } }
使用示例
<ScrollViewer CanContentScroll="True" HorizontalScrollBarVisibility="Visible"> <lw:ListWheelPanel Radius="160" ClipToBounds="True"> <lw:ListWheelPanel.Resources> <Style TargetType="{x:Type TextBlock}"> <Setter Property="FontSize" Value="24" /> </Style> </lw:ListWheelPanel.Resources> <TextBlock Text="Item 1" /> <TextBlock Text="Item 2" /> <TextBlock Text="Item 3" /> <TextBlock Text="Item 4" /> <TextBlock Text="Item 5" /> <TextBlock Text="Item 6" /> <TextBlock Text="Item 7" /> <TextBlock Text="Item 8" /> <TextBlock Text="Item 9" /> <TextBlock Text="Item 10" /> <TextBlock Text="Item 11" /> <TextBlock Text="Item 12" /> <TextBlock Text="Item 13" /> <TextBlock Text="Item 14" /> <TextBlock Text="Item 15" /> <TextBlock Text="Item 16" /> <TextBlock Text="Item 17" /> <TextBlock Text="Item 18" /> <TextBlock Text="Item 19" /> <TextBlock Text="Item 20" /> </lw:ListWheelPanel> </ScrollViewer>
问题原因及修复方案
核心问题
滚动条滑块无法移动的原因是HorizontalOffset和VerticalOffset返回了负数,而WPF滚动条要求偏移量必须是非负数值——它代表视口相对于内容起始位置的偏移,负数无法被滚动条的UI逻辑正确解析,导致滑块位置不更新。
从你的ConstrainPositionOffsetToRange方法可以看到,PositionOffset的取值范围是3 - _Positions.Count到0,全部为负数,直接返回给滚动条后无法正确映射滑块位置。
修复步骤
调整偏移量为非负范围
修改ConstrainPositionOffsetToRange方法,让PositionOffset的取值范围从0开始,最大值为可滚动的总偏移量:private static object ConstrainPositionOffsetToRange(DependencyObject d, object baseValue) { ListWheelPanel panel = (ListWheelPanel)d; // 最大可偏移量 = 总项数 - 可见项数 int maxOffset = Math.Max(0, panel._Positions.Count - (int)panel._NumberOfVisibleChildren); int input = (int)baseValue; // 限制在0到maxOffset之间 return Math.Clamp(input, 0, maxOffset); }修正滚动偏移的计算逻辑
原来的SetHorizontalOffset和SetVerticalOffset方法是通过减法调整偏移,现在改为加法,确保滚动方向正确:public void SetHorizontalOffset(double offset) { PositionOffset += (int)offset; } public void SetVerticalOffset(double offset) { PositionOffset += (int)offset; }同步面板内部的排列逻辑
调整ArrangeOverride中的位置计算,适配非负的PositionOffset:int currentPosition = (i - PositionOffset + _Positions.Count) % _Positions.Count;(这里用减法替代原来的加法,确保滚动时元素的旋转方向符合预期)
保持滚动条的偏移量正确返回
现在PositionOffset本身是非负的,直接返回即可:public double HorizontalOffset => PositionOffset; public double VerticalOffset => PositionOffset;
验证
修改后,滚动条会根据PositionOffset的非负数值正确计算滑块位置,点击滚动条、滚轮操作时,滑块会同步移动,同时面板的滚动逻辑保持正常。
内容的提问来源于stack exchange,提问作者user3257308

