自从研究了Java Swing的Graphics,觉得能按照自己的想法画出一些东西,还是挺有意思的,之前学习Java Swing的时候练习过一个模拟时钟,自己觉得不错,后来看到这个html5 canvas实现的模拟时钟(HTML5 canvas超逼真的模拟时钟特效),瞬间觉得自己之前的实现弱爆了,这个真是漂亮,忍不住想用自己有限的Swing Graphics知识山寨一个。看下山寨后的效果图吧
这是mac的运行效果,虽然有点锯齿感,总体还行,比html5还是差不少,h5的有指针的阴影效果,看起来比较有立体感,h5的表盘数字位置也比较准确,这两方面还差点,还有刻度的圆角矩形比例什么的还差点。ubuntu linux运行后,锯齿感非常明显,可能显卡驱动不行吧,windows目前没运行。
可以看出ubuntu linux的锯齿感非常强,代码开启下抗锯齿试试看
omg, 看来是没有开启抗锯齿效果的原因,开启后效果立马提升了几个档次……坐标轴只是个参考可以去掉,最新代码解决了时间数字与刻度贴合不够近的问题,算是比较完善了。
模仿实现思路如下:继承Jpanel重写paintComponent方法。
1.绘制表盘时间刻度
这一圈刻度看起来像圆角矩形,只是大小、长短有所不同。
- 方案1:使用的是圆角矩形RoundRectangle进行的实现,然而矩形默认只能水平或垂直,这时候就需要使用Graphics的rotate方法,将画布进行旋转,其实我觉得根本上来说,旋转的是画布中的坐标系,画布其实没转,一圈60个刻度,循环60次,每5个画一个略大的矩形即可。
- 方案2:不需要调用rotate旋转,通过改变画笔线条的粗细(stroke)和线条末端的形状(end_round)即可,通过BasicStroke构造方法传递相应的参数实现。显然这种方法更为简单方便。代码示例如下(kotlin实现,java也是一样道理)
var line: Line2D.Float
val big = BasicStroke(10.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
val small = BasicStroke(5.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
for (i in 0 until 60) {
var x1 = 0f
var y1 = 0f
var x2 = 0f
var y2 = 0f
if (i % 5 == 0) {
x1 = (Math.cos(theta * i) * radiusLong).toFloat()
y1 = (Math.sin(theta * i) * radiusLong).toFloat()
x2 = (Math.cos(theta * i) * radiusEnd).toFloat()
y2 = (Math.sin(theta * i) * radiusEnd).toFloat()
line = Line2D.Float(x1,y1,x2,y2)
g2.stroke = big
} else {
x1 = (Math.cos(theta * i) * (radiusLong+5)).toFloat()
y1 = (Math.sin(theta * i) * (radiusLong+5)).toFloat()
x2 = (Math.cos(theta * i) * radiusEnd).toFloat()
y2 = (Math.sin(theta * i) * radiusEnd).toFloat()
line = Line2D.Float(x1,y1,x2,y2)
g2.stroke = small
}
g2.draw(line)
}
之前文章draw heart的时候,以为graphics的形状不支持浮点数,是自己当时所知有限了,graphics2d提供了基本所有形状的2D版本,既支持float还支持double。如Line2D,Rectangle2D,RoundRect2D,Arc2D,Ellipse2D等等,它们都实现了Shape接口。
绘制刻度的时候,起始点其实是在x轴的正方向,也就是相当于3点钟方向,循环开始以后,是从这点顺时针开始绘制的,在swing graphics坐标系里面y轴的正方向是向下的,android里面也是这样,即使在windows里面也是,h5的canvas也是,看来这个在好多平台是统一的吧,研究了一下h5的代码实现,发现它在计算完y的值以后乘以了-1,也就是取反了,开始觉得很奇怪,在代码里面试了一下,发现这样相当于把y轴的正方向给改变为指向上方了,相当于变成了数学几何里面的那种标准平面直角坐标系,这样绘制的时候就是从3点钟,逆时针绘制了,殊途同归。下面是逆时针示例,顺时针看代码吧
通过查看H5的js实现代码,发现并没有处理从三点钟后退90度的操作,内心一直对此存着疑问,突然想起自己的实现使用的是斜线与x轴夹角进行计算实现的,那么他可能用的是斜线与y轴夹角进行的实现,仔细看了下,确实如此。默认情况,这种实现是从6点钟方向逆时针绘制,但是他将y坐标的值进行了取反后,一下就变成了从12点钟进行顺时针绘制了,厉害。这个时候的斜线与圆的交点坐标(x, y)的计算方式就变了:
x
=
r
∗
s
i
n
(
t
)
x = r * sin(t)
x=r∗sin(t)
y
=
r
∗
c
o
s
(
t
)
y = r * cos(t)
y=r∗cos(t)
依然可以参考下图的坐标示意图,只是夹角变成了斜线与y轴的夹角。
2.绘制表盘时间数字
主要是12个数字,分别对应于表盘上面的时间刻度,这个实现的时候需要考虑角度(弧度)的一个移动的问题,因为我们是从3点钟开始绘制的,如果不考虑这个问题,那就需要从3画到12,再画1,2。或者(顺时针实现方法)减去相应的度数,这样可以从1画到12。目前数字与刻度贴合的不够近…通过Graphics测量字符串宽度已经解决了
var numRadius = radius * 0.9f
g2.font = Font("", Font.PLAIN, 22)
var fontMetrics = getFontMetrics(g2.font)
//draw numbers
var numTheta = 2 * Math.PI / 12
for (i in 0..11) {
var x1 = Math.cos(numTheta * i - Math.PI / 3) * numRadius
var y1 = Math.sin(numTheta * i - Math.PI / 3) * numRadius
//println("x1:$x1, y1:$y1")
// 测量字符串宽度
var num = (i + 1).toString()
var strW = fontMetrics.stringWidth(num)
g2.drawString(num, (x1 - strW / 2).toFloat(), y1.toFloat())
}
3.绘制时间指针
这个不同指针的先后顺序要注意,按照这个h5的效果,先画时针、分针、秒针,然后在它们上面还有覆盖的一个圆圈,秒针有一个尾巴超出圆圈的覆盖在另一侧露出来了,这个细节也要实现。这些实现后,定义时分秒三个变量,取系统当前的时间进行分别赋值,运行后即可实现时间指向。
接着让时钟的指针动起来,需要创建一个Timer每一秒更新一次时间(启动一个线程也行),这样实现指针位置的变化。主要思路就是秒数或分钟数、小时数计算为对应的角度,这样计算出一个点的坐标,然后坐标原点(0,0)为另外一个点,在这两点进行drawLine即可,公式如下:
x
=
r
∗
c
o
s
(
t
)
x = r * cos(t)
x=r∗cos(t)
y
=
r
∗
s
i
n
(
t
)
y = r * sin(t)
y=r∗sin(t)
下面这个图片中斜线与圆的交点的坐标(x,y)就是这样计算出来的,三条线围起来的三角形,斜线(相当于斜边,值为圆的半径)与x轴的夹角t的cos三角函数值的乘积就得到了交点的x值,同样斜线与t的sin的乘积可得到交点的y值。这样弧度从0到2PI,即可实现获取一圈的坐标点,一定要用弧度,角度有问题。这个画图软件不支持文字(其实是对ubuntu画图软件还不太熟),大致就是这么个效果。
这个时间的分针和时针还有一个问题,就是H5这个实现是,秒针每走一次,分针都会相应变化的,而不是秒针走完一圈,分针一下从一个点指向下一个点,同理时针也是这个样子的。分针的实现是使用两个刻度之间的度数乘以当前(秒数/60),这个计算出来的度数加到分针的计算度数中即可实现,通过查看H5的js源代码发现,它不是这样实现的,它的分钟数是浮点数,分钟数 = 当前分钟数 + 当前秒数/60,这样也同样的实现了这个效果,殊途同归。时针也是一样的道理……
4.绘制时钟品牌名称
在钟表的6点钟以上,圆心以下绘制了2行文字,大小不同,一个是品牌名称,一个是所属country,需要测量一下文字字符串的宽度,再绘制比较准确。
5.根据系统时间进行时间的动态更新
可以使用Timer,每1000毫秒更新一次表盘的指针指向;也可以启动一个线程,sleep1000毫秒后通知ui线程更新表盘指针。
6.最终动态效果
这个gif有闪烁,不是太优雅…又给它去掉了
到这里山寨H5时钟的效果就全部结束了,虽然还有没完全模仿到的地方(比如指针的阴影效果,或许Android平台可以支持?下回分解吧),后续再研究吧(Swing Graphics路还很长),更多代码(有java实现)可以参考github:https://github.com/ximen502/SwingLearn
这里给出kotlin的完整实现代码
/**
* 第三版高仿H5模拟时钟部分代码优化
*/
class ClockFrameKt3 : JFrame() {
init {
val clockPanel = ClockPanel()
add(clockPanel)
clockPanel.start()
}
inner class ClockPanel : JPanel() {
val secondHandColor = Color(0xf3, 0xa8, 0x29)
val minuteHandColor = Color(0x22, 0x22, 0x22)
val hourHandColor = Color(0x22, 0x22, 0x22)
val markColor = Color(0x22, 0x22, 0x22)
var hour = 0
var minute = 0
var second = 0
override fun paintComponent(g: Graphics?) {
super.paintComponent(g)
var g2: Graphics2D = g as Graphics2D
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.translate(width / 2, height / 2)
//drawAxis(g2)
val theta = 2 * Math.PI / 60
val radius = 150f
val radiusLong = 164f
val radiusEnd = 178
g2.color = markColor
var line: Line2D.Float
val big = BasicStroke(10.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
val small = BasicStroke(5.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
for (i in 0 until 60) {
var x1 = 0f
var y1 = 0f
var x2 = 0f
var y2 = 0f
val cost = Math.cos(theta * i)
val sint = Math.sin(theta * i)
// hour marker
if (i % 5 == 0) {
x1 = (cost * radiusEnd * 0.90).toFloat()
y1 = (sint * radiusEnd * 0.90).toFloat()
x2 = (cost * radiusEnd).toFloat()
y2 = (sint * radiusEnd).toFloat()
line = Line2D.Float(x1,y1,x2,y2)
g2.stroke = big
} else {
// minute marker
x1 = (cost * radiusEnd * 0.95).toFloat()
y1 = (sint * radiusEnd * 0.95).toFloat()
x2 = (cost * radiusEnd).toFloat()
y2 = (sint * radiusEnd).toFloat()
line = Line2D.Float(x1,y1,x2,y2)
g2.stroke = small
}
g2.draw(line)
}
var numRadius = radius * 0.9f
g2.font = Font("", Font.PLAIN, 22)
var fontMetrics = getFontMetrics(g2.font)
//draw numbers
var numTheta = 2 * Math.PI / 12
for (i in 0..11) {
var x1 = Math.cos(numTheta * i - Math.PI / 3) * numRadius
var y1 = Math.sin(numTheta * i - Math.PI / 3) * numRadius
//println("x1:$x1, y1:$y1")
// 测量字符串宽度
var num = (i + 1).toString()
var strW = fontMetrics.stringWidth(num)
g2.drawString(num, (x1 - strW / 2).toFloat(), y1.toFloat())
}
var font = Font("",Font.PLAIN, 14)
var fontSmall = Font("",Font.PLAIN, 10)
val brand = "北极星"
val brandPlace = "亚洲"
fontMetrics = getFontMetrics(font)
var bW = fontMetrics.stringWidth(brand)
g2.font = font
g2.drawString(brand, 0 - bW / 2, radius.toInt() / 2)
fontMetrics = getFontMetrics(fontSmall)
var bpW = fontMetrics.stringWidth(brandPlace)
g2.font = fontSmall
g2.drawString(brandPlace, 0 - bpW / 2, radius.toInt() / 2 + 15)
var x1 = 0f
var y1 = 0f
var x2 = 0f
var y2 = 0f
var hourTheta = 2 * Math.PI / 12
//draw hour hand
//修线条粗细
var basicStroke = BasicStroke(20.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
g2.stroke = basicStroke
g2.color = hourHandColor
/* hourTheta * (minute / 60f)分针变化对时针的影响 2pi/12*(minute/60) */
x2 = radius * 0.75f * Math.cos(hourTheta * hour + hourTheta * (minute / 60f) - Math.PI / 2).toFloat()
y2 = radius * 0.75f * Math.sin(hourTheta * hour + hourTheta * (minute / 60f) - Math.PI / 2).toFloat()
var hourLine = Line2D.Float(x1, y1, x2, y2)
g2.draw(hourLine)
//println("second:$second, ${theta * (second / 60f)}")
//draw minute hand
basicStroke = BasicStroke(9.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
g2.stroke = basicStroke
g2.color = minuteHandColor
/* theta * (second / 60f)秒针变化对分针的影响 //2pi/60 * (second/60)*/
x2 = radiusLong * 1.0f * Math.cos(theta * (minute) + theta * (second / 60f) - Math.PI / 2).toFloat()
y2 = radiusLong * 1.0f * Math.sin(theta * (minute) + theta * (second / 60f) - Math.PI / 2).toFloat()
var minuteLine = Line2D.Float(x1, y1, x2, y2)
g2.draw(minuteLine)
//draw second hand
basicStroke = BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
g2.stroke = basicStroke
g2.color = secondHandColor
x2 = radiusLong * 1.08f * Math.cos(theta * (second) - Math.PI / 2).toFloat()
y2 = radiusLong * 1.08f * Math.sin(theta * (second) - Math.PI / 2).toFloat()
var secondLine = Line2D.Float(x1, y1, x2, y2)
g2.draw(secondLine);
//draw second tail handle
basicStroke = BasicStroke(13.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_MITER)
g2.stroke = basicStroke
g2.color = secondHandColor
x2 = radiusLong * 0.1f * Math.cos(theta * (second) - Math.PI / 2).toFloat()
y2 = radiusLong * 0.1f * Math.sin(theta * (second) - Math.PI / 2).toFloat()
secondLine = Line2D.Float(x1, y1, -x2, -y2)
g2.draw(secondLine);
// draw circle cover three hands
var worh = 16f
var circle = Ellipse2D.Float(0f - worh / 2f, 0f - worh / 2f, worh, worh)
g2.draw(circle)
}
fun drawAxis(g: Graphics?) {
var g2: Graphics2D = g as Graphics2D
g2.stroke = BasicStroke(1.0f)
g2.color = Color.BLACK
g2.drawLine(-width / 2, 0, width / 2, 0)
g2.drawLine(0, -height / 2, 0, height / 2)
//unit=10,vertical line,x1,y1,x2,y2
// short line, long line
val sl = 5
val ll = 10
//x axis
for (i in 0..width / 2 step 10) {
if (i % 50 == 0) {
g2.drawLine(i, 0, i, -ll)
g2.drawLine(-i, 0, -i, -ll)
} else {
g2.drawLine(i, 0, i, -sl)
g2.drawLine(-i, 0, -i, -sl)
}
}
//println(height / 2)
//y axis
for (i in 0..height / 2 step 10) {
if (i % 50 == 0) {
g2.drawLine(0, i, ll, i)
g2.drawLine(0, -i, ll, -i)
} else {
g2.drawLine(0, i, sl, i)
g2.drawLine(0, -i, sl, -i)
}
}
}
override fun getPreferredSize(): Dimension {
return Dimension(250, 250)
}
private fun setCurrentTime(hour: Int, minute: Int, second: Int) {
this.hour = hour
this.minute = minute
this.second = second
repaint()
}
fun start() {
var c = 0
var timer = Timer(1000) {
val calendar = Calendar.getInstance()
var hour = calendar.get(Calendar.HOUR_OF_DAY)
var minute = calendar.get(Calendar.MINUTE)
var second = calendar.get(Calendar.SECOND)
setCurrentTime(hour % 12, minute, second)
}
timer.start()
}
}
//转弧度
fun toRadians(deg: Float): Float {
return ((Math.PI / 180) * deg).toFloat();
}
}
fun main(args: Array<String>) {
var frame = ClockFrameKt3()
frame.apply {
setSize(500, 450)
title = "Kotlin clock"
setLocationRelativeTo(null) // Center the frame
defaultCloseOperation = JFrame.EXIT_ON_CLOSE
isVisible = true
}
}