You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自定义WPF ListWheelPanel实现IScrollInfo后滚动条滑块不移动问题

自定义WPF圆形滚动面板滚动条滑块无法移动问题排查

问题描述

我创建了一个可将子项环绕圆形排列的自定义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,全部为负数,直接返回给滚动条后无法正确映射滑块位置。

修复步骤

  1. 调整偏移量为非负范围
    修改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);
    }
    
  2. 修正滚动偏移的计算逻辑
    原来的SetHorizontalOffset和SetVerticalOffset方法是通过减法调整偏移,现在改为加法,确保滚动方向正确:

    public void SetHorizontalOffset(double offset)
    {
        PositionOffset += (int)offset;
    }
    
    public void SetVerticalOffset(double offset)
    {
        PositionOffset += (int)offset;
    }
    
  3. 同步面板内部的排列逻辑
    调整ArrangeOverride中的位置计算,适配非负的PositionOffset:

    int currentPosition = (i - PositionOffset + _Positions.Count) % _Positions.Count;
    

    (这里用减法替代原来的加法,确保滚动时元素的旋转方向符合预期)

  4. 保持滚动条的偏移量正确返回
    现在PositionOffset本身是非负的,直接返回即可:

    public double HorizontalOffset => PositionOffset;
    public double VerticalOffset => PositionOffset;
    

验证

修改后,滚动条会根据PositionOffset的非负数值正确计算滑块位置,点击滚动条、滚轮操作时,滑块会同步移动,同时面板的滚动逻辑保持正常。

内容的提问来源于stack exchange,提问作者user3257308

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 02:15:54