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

Flutter自定义圆形音量滑块:指示器角度异常求助

圆形音量滑块指示器角度偏差修复方案

我需要实现一个圆形音量滑块,第三方包无法满足定制需求,自己写了Widget后功能基本可用,但指示器无法随进度正确移动,角度存在偏差。

我的代码如下:

import 'package:flutter/material.dart';
import 'dart:math';

void main() {
   runApp(MaterialApp(home: CircularVolumeSlider()));
 }

class CircularVolumeSlider extends StatefulWidget {
  @override
  _CircularVolumeSliderState createState() => _CircularVolumeSliderState();
}

class _CircularVolumeSliderState extends State<CircularVolumeSlider> {
  double _sliderValue = 0.0; // Initial value (0-100)
  Offset _previousOffset = Offset(0, 0);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            // Circular Slider
            GestureDetector(
                onPanUpdate: (DragUpdateDetails details) {
                  final currentOffset = details.localPosition;
                  final center = Offset(200, 200) / 2.0; // Use Offset

                  // Calculate the vector from the center to the current and previous points
                  final currentVector = currentOffset - center;
                  final previousVector = _previousOffset - center;

                  final distance = currentVector.distance;
                  if (distance <= 75) {
                    return;
                  }
                  // Calculate the angle between the vectors
                  final angle = atan2(currentVector.dy, currentVector.dx) -
                      atan2(previousVector.dy, previousVector.dx);

                  // Calculate the value based on the angle and adjust to the correct range
                  double newValue =
                      (_sliderValue + angle / (2 * pi) * 100.0) % 100.0;

                  if (newValue < 0) {
                    newValue += 100;
                  }

                  setState(() {
                    _sliderValue = newValue;
                  });

                  // Store the current offset for the next update
                  _previousOffset = currentOffset;
                },
                onPanStart: (details) {
                  _previousOffset = details.localPosition;
                },
                child: Container(
                  width: 230,
                  height: 230,
                  decoration: BoxDecoration(
                    shape: BoxShape.circle,
                  ),
                  child: Stack(
                    alignment: Alignment.center,
                    children: [
                      Container(
                        width: 200, // Set your desired size
                        height: 200,
                        decoration: const BoxDecoration(
                          color: Colors.yellow,
                          shape: BoxShape.circle,
                        ),
                        child: CircularProgressIndicator(
                          value: _sliderValue / 100,
                          strokeWidth: 12, // Customize the appearance
                        ),
                      ),
                      // Vertical Line Indicator
                      Positioned(
                          // Adjust the position
                          top: _previousOffset.dy,
                          left: _previousOffset.dx,
                          child: Transform.rotate(
                            angle: (_sliderValue / 100 * 2 * pi) ,
                            child: Container(
                                width: 10, // Set the line width
                                height: 30, // Set the line height

                                decoration: BoxDecoration(
                                    color: Colors.red,
                                    borderRadius: BorderRadius.circular(
                                        6)) // Customize the color
                                ),
                          )),
                    ],
                  ),
                )),
            const SizedBox(height: 30),
            // Indicator View (you can use GestureDetector for swiping)
            // Percentage Display
            Column(
              children: [
                const Text(
                  'Volume',
                  style: TextStyle(fontSize: 20),
                ),
                Text(
                  '$_sliderValue%',
                  style: TextStyle(fontSize: 24),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

问题分析

  1. 指示器位置错误:直接使用触摸点的_previousOffset作为指示器位置,会导致指示器跟着触摸点乱跑,无法固定在圆形轨迹上;同时Transform.rotate的角度未与CircularProgressIndicator的起始角度对齐。
  2. 角度基准不匹配:CircularProgressIndicator的进度起始角度是-π/2(顶部),而atan2的0角度是右侧,两者基准不一致直接导致角度偏差。
  3. 触摸逻辑累积误差:基于上一次触摸点的角度差计算滑块值,容易产生累积误差,不如直接根据当前触摸点计算对应进度更准确。

修复后的代码

import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(const MaterialApp(home: CircularVolumeSlider()));
}

class CircularVolumeSlider extends StatefulWidget {
  const CircularVolumeSlider({super.key});

  @override
  _CircularVolumeSliderState createState() => _CircularVolumeSliderState();
}

class _CircularVolumeSliderState extends State<CircularVolumeSlider> {
  double _sliderValue = 0.0; // 0-100
  final double _circleRadius = 100; // 黄色圆的半径
  final double _indicatorRadius = 115; // 指示器所在轨迹的半径

  // 将触摸点转换为滑块值
  double _calculateValueFromOffset(Offset offset) {
    final center = Offset(_circleRadius, _circleRadius);
    final vector = offset - center;
    // 计算角度,调整基准到顶部(-π/2),并转换为0-1的范围
    double angle = atan2(vector.dy, vector.dx) - (-pi/2);
    if (angle < 0) angle += 2 * pi;
    return (angle / (2 * pi)) * 100;
  }

  // 计算指示器的位置
  Offset _calculateIndicatorPosition() {
    final center = Offset(_circleRadius, _circleRadius);
    // 基于滑块值计算角度,起始点为顶部
    double angle = (_sliderValue / 100) * 2 * pi - pi/2;
    final dx = center.dx + _indicatorRadius * cos(angle);
    final dy = center.dy + _indicatorRadius * sin(angle);
    return Offset(dx, dy);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            GestureDetector(
              onPanUpdate: (DragUpdateDetails details) {
                final currentOffset = details.localPosition;
                final center = Offset(_circleRadius, _circleRadius);
                final distance = (currentOffset - center).distance;
                // 只响应圆形轨迹范围内的触摸
                if (distance < _circleRadius - 10 || distance > _indicatorRadius + 10) return;
                setState(() {
                  _sliderValue = _calculateValueFromOffset(currentOffset);
                });
              },
              onPanStart: (DragStartDetails details) {
                setState(() {
                  _sliderValue = _calculateValueFromOffset(details.localPosition);
                });
              },
              child: Container(
                width: _indicatorRadius * 2 + 20,
                height: _indicatorRadius * 2 + 20,
                child: Stack(
                  alignment: Alignment.center,
                  children: [
                    // 背景黄色圆
                    Container(
                      width: _circleRadius * 2,
                      height: _circleRadius * 2,
                      decoration: const BoxDecoration(
                        color: Colors.yellow,
                        shape: BoxShape.circle,
                      ),
                    ),
                    // 进度环
                    SizedBox(
                      width: _circleRadius * 2,
                      height: _circleRadius * 2,
                      child: CircularProgressIndicator(
                        value: _sliderValue / 100,
                        strokeWidth: 12,
                        strokeCap: StrokeCap.round,
                      ),
                    ),
                    // 指示器
                    Positioned(
                      left: _calculateIndicatorPosition().dx - 5, // 减去指示器宽度的一半
                      top: _calculateIndicatorPosition().dy - 15, // 减去指示器高度的一半
                      child: Container(
                        width: 10,
                        height: 30,
                        decoration: BoxDecoration(
                          color: Colors.red,
                          borderRadius: BorderRadius.circular(6),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 30),
            Column(
              children: [
                const Text(
                  '音量',
                  style: TextStyle(fontSize: 20),
                ),
                Text(
                  '${_sliderValue.toStringAsFixed(0)}%',
                  style: const TextStyle(fontSize: 24),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

关键修改点

  • 指示器位置计算:根据滑块值对应的角度,从中心向外计算指示器的固定轨迹位置,确保指示器始终在圆形边缘。
  • 角度基准对齐:将atan2的计算结果减去-π/2,让进度的起始点和CircularProgressIndicator保持一致(顶部)。
  • 触摸逻辑优化:直接根据当前触摸点计算对应的滑块值,避免角度差累积的误差,同时限制触摸响应的范围,提升交互体验。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.07 08:04:56