一、项目介绍
1.1 背景
在现代数据可视化领域,饼图(Pie Chart)因其直观展示各部分占整体比例而被广泛采用。为了增强互动性和吸引力,常会赋予饼图 旋转 动画:自动、平滑地旋转,让用户从不同角度重点查看扇区。旋转角度可以突出数据变化、引导观看顺序、提升界面动感。然而,要在 Java Swing/Java2D 环境下实现一个既平滑又可交互的饼图旋转,需要深入掌握以下难点:
-
角度映射:将时间或帧数映射到旋转角度,并与饼图扇区正确对齐。
-
绘制顺序:在旋转过程中,正确处理扇区的绘制顺序,避免前后扇区遮挡错乱。
-
动画驱动:使用
javax.swing.Timer
或高精度定时器控制旋转流畅度。 -
交互响应:支持暂停/继续、方向切换、速率调节及拖拽控制。
1.2 目标
本文将从零开始,手把手实现一个 Java2D Swing 版 的 可旋转饼图组件,重点在于:
-
自动旋转:按设定速率平滑且连续地旋转。
-
角度控制:可随时获取与设置当前旋转角度,实现“瞬时跳转”或动画过渡。
-
方向切换:顺时针或逆时针旋转可动态切换。
-
拖拽控制:鼠标拖拽实时控制饼图角度,打断/恢复自动旋转。
-
完整封装:提供易用 API,支持在任意 Swing 界面中嵌入。
二、相关技术与知识
要实现以上功能,需要掌握和理解以下技术要点。
2.1 Java2D 绘图基础
-
Graphics2D
:Java2D 的核心渲染上下文,支持抗锯齿、变换、复合等。 -
形状构造:
Arc2D
绘制扇形,Path2D
构造侧面形状。 -
抗锯齿:通过
RenderingHint.KEY_ANTIALIASING
提升绘图质量。 -
透明度:使用
AlphaComposite
控制半透明效果。
2.2 动画驱动
-
Swing Timer:
javax.swing.Timer
在事件分发线程(EDT)触发周期性事件,安全刷图。 -
帧率与速率:根据延迟(delay)和每分钟旋转度数(RPM)计算每帧增量角度
delta = rpm * 360° / (60_000ms / delay)
。 -
平滑度:选择合适的
delay
(例如 16ms≈60FPS 或 40ms≈25FPS)平衡流畅度与性能。
2.3 深度排序
虽然我们演示的是 2D 饼图,但若添加 3D 侧面 或 阴影,则需要 深度排序:在每帧根据扇区当前中心角度的正余弦值判断其“前后”关系,先画远处扇区再画近处扇区,保证遮挡效果自然。
2.4 交互处理
-
鼠标拖拽:
MouseListener
+MouseMotionListener
捕获按下、拖拽、释放事件,实时映射拖动距离到角度偏移。 -
暂停/恢复:拖拽开始时停止自动旋转,释放时可继续。
-
方向切换与速率调节:通过暴露 API 允许调用者动态更改
rpm
与clockwise
标志。
三、实现思路
结合上述技术栈,我们将按以下思路实现:
-
数据模型
-
定义内部
PieSlice
类:保存扇区value
、color
、label
、startAngle
、arcAngle
。 -
totalValue
累加所有扇区数值。 -
computeAngles()
方法按比例分配角度。
-
-
组件封装
-
继承
JPanel
,命名为RotatingPieChartPanel
,暴露 API:-
addSlice(value, color, label)
-
setRotateSpeed(rpm)
-
setClockwise(boolean)
-
start()
/stop()
-
setAngle(double)
/getAngle()
实现“瞬时跳转”。
-
-
-
动画与绘制
-
在构造器中创建
Timer(animationDelay, e->{ advanceOffset(); repaint(); })
。 -
advanceOffset()
根据rpm
与clockwise
计算angleOffset
。 -
paintComponent()
中调用drawPie()
,分三步:阴影 → 侧面(需深度排序) → 顶面。
-
-
交互
-
添加
MouseAdapter
:-
mousePressed
开始拖拽,记录初始angleOffset
与鼠标点; -
mouseDragged
根据水平方向位移映射到增量角度,更新angleOffset
并repaint()
; -
mouseReleased
结束拖拽,重启动画。
-
-
-
深度排序
-
在绘制侧面时,先复制扇区列表,按每个扇区 中心角度 的正弦值(或余弦值)排序;
-
depthKey = Math.sin(Math.toRadians(startAngle + arcAngle/2 + angleOffset))
,值大者后绘制。
-
四、完整实现代码
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.util.*;
import java.util.List;
/**
* RotatingPieChartPanel:可自动/手动旋转且正确排序的饼图组件
*/
public class RotatingPieChartPanel extends JPanel {
/** 内部扇区模型 */
private static class PieSlice {
double value; // 扇区数值
Color color; // 扇区颜色
String label; // 扇区标签
double startAngle; // 起始角度(度)
double arcAngle; // 扇区角度(度)
boolean highlighted; // 是否高亮
PieSlice(double value, Color color, String label) {
this.value = value;
this.color = color;
this.label = label;
this.highlighted = false;
}
}
private final List<PieSlice> slices = new ArrayList<>();
private double totalValue = 0.0;
// 旋转控制
private double angleOffset = 0.0; // 当前偏移角度
private double rpm = 1.0; // 每分钟度数
private boolean clockwise = true; // 旋转方向
private Timer animationTimer; // 用于自动旋转
// 3D 效果深度(像素)
private double depth = 50.0;
// 拖拽交互状态
private boolean dragging = false;
private double dragStartOffset;
private Point dragStartPoint;
public RotatingPieChartPanel() {
setBackground(Color.WHITE);
setPreferredSize(new Dimension(600, 400));
initInteraction();
}
/** 初始化鼠标交互:拖拽控制 */
private void initInteraction() {
MouseAdapter ma = new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
// 停止自动旋转,进入拖拽状态
stop();
dragging = true;
dragStartOffset = angleOffset;
dragStartPoint = e.getPoint();
}
@Override
public void mouseDragged(MouseEvent e) {
if (!dragging) return;
Point pt = e.getPoint();
double dx = pt.x - dragStartPoint.x;
// 每像素对应 0.5 度
angleOffset = dragStartOffset + dx * 0.5;
repaint();
}
@Override
public void mouseReleased(MouseEvent e) {
dragging = false;
start(); // 恢复自动旋转
}
};
addMouseListener(ma);
addMouseMotionListener(ma);
}
/** 添加扇区 */
public void addSlice(double value, Color color, String label) {
slices.add(new PieSlice(value, color, label));
totalValue += value;
computeAngles();
repaint();
}
/** 重新计算扇区角度 */
private void computeAngles() {
double angle = 0.0;
for (PieSlice s : slices) {
s.startAngle = angle;
s.arcAngle = s.value / totalValue * 360.0;
angle += s.arcAngle;
}
}
/** 设置旋转速率(RPM) */
public void setRotateSpeed(double rpm) {
this.rpm = rpm;
if (animationTimer != null && animationTimer.isRunning()) {
stop();
start();
}
}
/** 设置旋转方向 */
public void setClockwise(boolean cw) {
this.clockwise = cw;
}
/** 设置 3D 深度 */
public void setDepth(double depth) {
this.depth = depth;
repaint();
}
/** 启动自动旋转 */
public void start() {
if (animationTimer != null && animationTimer.isRunning()) return;
int delay = 40; // 25 FPS
double deltaDeg = rpm * 360.0 / (60_000.0 / delay);
animationTimer = new Timer(delay, e -> {
angleOffset += (clockwise ? -deltaDeg : deltaDeg);
repaint();
});
animationTimer.start();
}
/** 停止自动旋转 */
public void stop() {
if (animationTimer != null) {
animationTimer.stop();
animationTimer = null;
}
}
/** 获取当前角度 */
public double getAngle() {
return angleOffset;
}
/** 直接设置角度(瞬时跳转) */
public void setAngle(double angle) {
this.angleOffset = angle % 360.0;
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
renderPie((Graphics2D) g);
}
/** 绘制饼图:阴影 → 侧面(深度排序) → 顶面 */
private void renderPie(Graphics2D g2) {
// 抗锯齿
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
int w = getWidth(), h = getHeight();
double cx = w / 2.0, cy = h / 2.0 - depth / 2.0;
double r = Math.min(w, h - depth) / 2.0 - 20.0;
// 1. 绘制阴影
drawShadow(g2, cx, cy, r);
// 2. 深度排序并绘制侧面
List<PieSlice> sorted = new ArrayList<>(slices);
sorted.sort(Comparator.comparingDouble(this::depthKey));
for (PieSlice s : sorted) {
drawSide(g2, cx, cy, r, s);
}
// 3. 绘制顶面
for (PieSlice s : sorted) {
drawTop(g2, cx, cy, r, s);
}
}
/** 计算深度排序 key:扇区中心角度的 sin 值 */
private double depthKey(PieSlice s) {
double mid = s.startAngle + s.arcAngle / 2.0 + angleOffset;
return Math.sin(Math.toRadians(mid));
}
/** 绘制底部阴影 */
private void drawShadow(Graphics2D g2,
double cx, double cy, double r) {
Ellipse2D shadow = new Ellipse2D.Double(
cx - r, cy + depth - r / 3.0 * 2, 2 * r, r / 2.0
);
Composite old = g2.getComposite();
g2.setComposite(AlphaComposite.getInstance(
AlphaComposite.SRC_OVER, 0.3f
));
g2.setColor(Color.BLACK);
g2.fill(shadow);
g2.setComposite(old);
}
/** 绘制扇区侧面 */
private void drawSide(Graphics2D g2,
double cx, double cy, double r, PieSlice s) {
double sa = Math.toRadians(s.startAngle + angleOffset);
double ea = Math.toRadians(s.startAngle + s.arcAngle + angleOffset);
Point2D p1 = new Point2D.Double(
cx + r * Math.cos(sa), cy + r * Math.sin(sa)
);
Point2D p2 = new Point2D.Double(
cx + r * Math.cos(ea), cy + r * Math.sin(ea)
);
Point2D p3 = new Point2D.Double(p2.getX(), p2.getY() + depth);
Point2D p4 = new Point2D.Double(p1.getX(), p1.getY() + depth);
Path2D side = new Path2D.Double();
side.moveTo(p1.getX(), p1.getY());
side.lineTo(p4.getX(), p4.getY());
side.lineTo(p3.getX(), p3.getY());
side.lineTo(p2.getX(), p2.getY());
side.closePath();
g2.setColor(s.color.darker());
g2.fill(side);
if (s.highlighted) {
g2.setColor(Color.WHITE);
g2.setStroke(new BasicStroke(2));
g2.draw(side);
}
}
/** 绘制扇区顶面 */
private void drawTop(Graphics2D g2,
double cx, double cy, double r, PieSlice s) {
Arc2D top = new Arc2D.Double(
cx - r, cy - r, 2 * r, 2 * r,
s.startAngle + angleOffset,
s.arcAngle, Arc2D.PIE
);
g2.setColor(s.color);
g2.fill(top);
if (s.highlighted) {
g2.setColor(Color.WHITE);
g2.setStroke(new BasicStroke(2));
g2.draw(top);
}
}
// 可扩展:添加高亮与提示功能
}
/**
* DemoMain:演示 RotatingPieChartPanel 用法
*/
class DemoMain {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
RotatingPieChartPanel pie = new RotatingPieChartPanel();
pie.addSlice(30, Color.RED, "红");
pie.addSlice(20, Color.BLUE, "蓝");
pie.addSlice(40, Color.GREEN, "绿");
pie.addSlice(10, Color.ORANGE,"橙");
pie.setDepth(60);
pie.setRotateSpeed(2.5);
pie.setClockwise(false);
pie.start();
JFrame f = new JFrame("可旋转饼图示例");
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
f.add(pie);
f.pack();
f.setLocationRelativeTo(null);
f.setVisible(true);
});
}
}
五、方法级功能解读
-
addSlice(value, color, label)
-
创建
PieSlice
对象,累加totalValue
,调用computeAngles()
重新计算所有扇区的角度分布。
-
-
computeAngles()
-
遍历
slices
列表,按比例(value / totalValue) * 360°
分配各扇区arcAngle
,并依次累加startAngle
。
-
-
start()
/stop()
-
使用
javax.swing.Timer
:delay = 40ms
,每次actionPerformed
中计算增量
-
double deltaDeg = rpm * 360.0 / (60_000.0 / delay);
angleOffset += clockwise ? -deltaDeg : deltaDeg;
-
-
调用
repaint()
刷新组件。
-
-
paintComponent(...)
-
先
super.paintComponent(g)
清除背景,然后调用renderPie(g2)
:-
启用抗锯齿
-
计算中心
(cx,cy)
与半径r
-
调用
drawShadow
、drawSide
(深度排序)和drawTop
-
-
-
depthKey(PieSlice s)
-
计算扇区中心角度:
s.startAngle + s.arcAngle/2 + angleOffset
-
取正弦值作为深度排序依据(越大越“前”),并对列表排序,保证先画“后面”的侧面,再画“前面”的侧面与顶面。
-
-
drawShadow
-
底部绘制半透明黑色椭圆,使用
AlphaComposite
设为 0.3f。
-
-
drawSide
-
计算扇区边缘两点
(p1, p2)
,并向下延伸depth
得到底部两点(p4, p3)
; -
构造
Path2D
四边形填充较暗颜色;
-
-
drawTop
-
使用
Arc2D.PIE
绘制扇形顶面;
-
-
拖拽交互
-
mousePressed
中停止自动旋转并记录初始状态; -
mouseDragged
根据水平位移映射到增量角度更新angleOffset
; -
mouseReleased
中恢复自动旋转。
-
六、项目总结与扩展思考
6.1 核心收获
-
深入理解 Java2D 在复杂动态图形中的应用技巧;
-
掌握 旋转动画 与 帧率控制 的实现;
-
学会使用 深度排序 解决旋转遮挡问题;
-
熟悉 拖拽交互 在图形组件中的集成。
6.2 性能优化建议
-
Shape 缓存:对每个扇区在固定角度步长下预生成
Path2D
与Arc2D
,避免每帧大量对象创建。 -
离屏缓冲:使用
BufferedImage
或VolatileImage
离屏渲染静态部分(阴影、侧面基础形状),只动态绘制旋转部分。 -
OpenGL 加速:设置系统属性
-Dsun.java2d.opengl=true
启用硬件加速。
6.3 扩展功能
-
渐变与纹理:为扇面添加渐变填充或贴图。
-
多层饼图/环形图:支持环形(Donut)或嵌套饼图。
-
标签与引导线:在旋转中动态显示标签,引导线可选显示。
-
JavaFX 版本:基于 JavaFX Canvas 或 3D API 实现更高性能和光照效果。