前言:
之前看过别人写的 js实现的 时钟表盘 挺有意思的,看着挺好 这边打算自己手动实现以下。顺便记录下实现过程:大致效果如下:
主要技术点:
表盘内样
倒角:
表盘下半部分是有一点倒角的感觉,实际是是两个 半径相差不多的圆,以上对齐的方式实现的。下面的圆稍微大点有个相对较深的颜色,然后上面在该一个白的圆。
表盘刻度内阴影:
flutter 实际上是不支持 内阴影的。我们这里一个带阴影的圆 通过 ClipRRect 裁切的方式实现的
表盘刻度
表盘的刻度,主要是还是利用 正弦函数 和 余弦函数,已知圆的半径 来计算 圆上的一个点。因为计算机的 0度实在 x轴方向。所以在 本例子里面 很多地方需要将起始角度 逆时针 旋转 -90 度。来对齐 秒针 和 分针 时针 的起始位置。
小时
//数字时间 List<Positioned> _timeNum(Size s) {final List<Positioned> timeArray = [];//默认起始角度,默认为3点钟方向,定位到12点钟方向 逆时针 90度const double startAngle = -pi / 2;final double radius = s.height / 2 - 25;final Size center = Size(s.width / 2 - 5, s.height / 2 - 6);int angle;double endAngle;for (int i = 12; i > 0; i--) {angle = 30 * i;endAngle = ((2 * pi) / 360) * angle + startAngle;double x = center.width + cos(endAngle) * radius;double y = center.height + sin(endAngle) * radius;timeArray.add(Positioned(left: x - 5,top: y,child: Container(width: 20,// color: Colors.blue,child: Text('$i',textAlign: TextAlign.center,),),),);}return timeArray; }
表盘指针
指针实际上是通过 ClipPath 来裁切一个 带颜色的 Container:已知 Container 大小,确定四个点的位置:起始点(0,0)位置 在 左上角
秒针的实现
Widget _pointerSecond() {return SizedBox(width: 120,height: 10,child: ClipPath(clipper: SecondPath(),child: Container(decoration: const BoxDecoration(color: Colors.red,),),),); }
辅助秒针类:
class SecondPath extends CustomClipper<Path> {@overridePath getClip(Size size) {var path = Path();path.moveTo(size.width / 3, 0);path.lineTo(size.width, size.height / 2);path.lineTo(size.width / 3, size.height);path.lineTo(0, size.height / 2);return path;}@overridebool shouldReclip(CustomClipper<Path> oldClipper) {return true;} }
针动起来
这里主要是通过 隐式动画 AnimatedRotation 只要修改他的旋转就能自己实现转动,传入一个 旋转的圈数,来实现移动的动画:
Center(child: Transform.rotate(angle: -pi / 2,child: AnimatedRotation(//圈数 1 >一圈, 0.5 半圈turns: _turnsSecond,duration:const Duration(milliseconds: 250),child: Padding(padding:const EdgeInsets.only(left: 30),child: _pointerSecond(),),),), ),
这里有个小插曲是 圈数开始到下一圈的时候 要累加一个圈数进去,才能继续往顺时针 方向继续旋转
完整代码:
import 'dart:async';
import 'dart:math';import 'package:flutter/material.dart';class PageTime extends StatefulWidget {const PageTime({Key? key}) : super(key: key);@overrideState<PageTime> createState() => _PageTimeState();
}class _PageTimeState extends State<PageTime> {Timer? _timer;DateTime _dateTime = DateTime.now();int _timeSecond = 0;int _timeMinute = 0;int _timeHour = 0;//圈数int _turnSecond = 0;//圈数int _turnMinute = 0;//圈数int _turnHour = 0;///秒的圈数double get _turnsSecond {if (_timeSecond == 0) {_turnSecond++;}return _turnSecond + _timeSecond / 60;}double get _turnsMinute {if (_timeMinute == 0) {_turnMinute++;}return _turnMinute + _timeMinute / 60;}double get _turnsHour {if (_timeHour % 12 == 0) {_turnHour++;}return _turnHour + (_timeHour % 12) / 12;}@overridevoid initState() {// TODO: implement initStatesuper.initState();_timeSecond = _dateTime.second;_timeMinute = _dateTime.minute;_timeHour = _dateTime.hour;_timer = Timer.periodic(const Duration(seconds: 1),(timer) {setState(() {_dateTime = DateTime.now();_timeSecond = _dateTime.second;_timeMinute = _dateTime.minute;_timeHour = _dateTime.hour;});},);}@overridevoid dispose() {_timer?.cancel();// TODO: implement disposesuper.dispose();}@overrideWidget build(BuildContext context) {return Scaffold(backgroundColor: Colors.blueGrey,appBar: AppBar(title: const Text('时钟'),centerTitle: true,),body: Center(child: Column(mainAxisSize: MainAxisSize.min,children: [// Container(// height: 50,// width: 180,// color: Colors.white,// child: Center(// child: Text(// '$_timeHour-$_timeMinute:$_timeSecond')),// ),Container(width: 260,height: 260,decoration: BoxDecoration(color: Colors.white70,borderRadius: BorderRadius.circular(130),boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.3),spreadRadius: 2,blurRadius: 5,offset: const Offset(0, 6), // 阴影的偏移量),],),child: Align(alignment: Alignment.topCenter,child: Container(width: 255,height: 255,decoration: BoxDecoration(color: Colors.white,borderRadius: BorderRadius.circular(255 / 2),boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.3),spreadRadius: 2,blurRadius: 5,offset: const Offset(0, 6), // 阴影的偏移量),],),child: Center(child: ClipRRect(borderRadius: BorderRadius.circular(110),child: Container(width: 220,height: 220,decoration: BoxDecoration(// color: Colors.transparent,borderRadius: BorderRadius.circular(110),gradient: RadialGradient(colors: [Colors.white,Colors.black.withOpacity(0.2),],stops: const [0.50, 1.0],center: Alignment.center,radius: 0.9, // 渐变的半径,从圆心到边缘),),child: Stack(children: [..._timeScale(const Size(220, 220)),..._timeNum(const Size(220, 220)),Center(child: Container(width: 140,height: 140,// color: Colors.blue,child: Stack(children: [Center(child: Transform.rotate(angle: -pi / 2,child: AnimatedRotation(turns: _turnsHour,duration: const Duration(seconds: 1),child: Padding(padding:const EdgeInsets.only(left: 30),child: _pointerHour(),),),),),Center(child: Transform.rotate(angle: -pi / 2,child: AnimatedRotation(turns: _turnsMinute,duration: const Duration(seconds: 1),child: Padding(padding:const EdgeInsets.only(left: 30),child: _pointerMinute(),),),),),Center(child: Transform.rotate(angle: -pi / 2,child: AnimatedRotation(//圈数 1 >一圈, 0.5 半圈turns: _turnsSecond,duration:const Duration(milliseconds: 250),child: Padding(padding:const EdgeInsets.only(left: 30),child: _pointerSecond(),),),),),Center(child: Container(width: 20,height: 20,decoration: BoxDecoration(color: Colors.white,boxShadow: [BoxShadow(color:Colors.black.withOpacity(0.3),spreadRadius: 2,blurRadius: 5,offset: Offset(0, 6), // 阴影的偏移量),],borderRadius:BorderRadius.circular(10),),),),],),),)],),),),),),),),Padding(padding: const EdgeInsets.symmetric(vertical: 24),child: _pointerSecond(),),_pointerMinute(),_pointerHour(),],),),);}//数字时间List<Positioned> _timeNum(Size s) {final List<Positioned> timeArray = [];//默认起始角度,默认为3点钟方向,定位到12点钟方向 逆时针 90度const double startAngle = -pi / 2;final double radius = s.height / 2 - 25;final Size center = Size(s.width / 2 - 5, s.height / 2 - 6);int angle;double endAngle;for (int i = 12; i > 0; i--) {angle = 30 * i;endAngle = ((2 * pi) / 360) * angle + startAngle;double x = center.width + cos(endAngle) * radius;double y = center.height + sin(endAngle) * radius;timeArray.add(Positioned(left: x - 5,top: y,child: Container(width: 20,// color: Colors.blue,child: Text('$i',textAlign: TextAlign.center,),),),);}return timeArray;}//刻度时间List<Positioned> _timeScale(Size s) {final List<Positioned> timeArray = [];//默认起始角度,默认为3点钟方向,定位到12点钟方向// const double startAngle = -pi / 2;const double startAngle = 0;final double radius = s.height / 2 - 10;final Size center = Size(s.width / 2 - 3, s.height / 2 + 3);int angle;double endAngle;for (int i = 60; i > 0; i--) {angle = 6 * i;endAngle = ((2 * pi) / 360) * angle + startAngle;double x = 0;double y = 0;x = center.width + cos(endAngle) * radius;y = center.height + sin(endAngle) * radius;// if (i % 5 == 0) {// x = center.width + cos(endAngle) * (radius - 0);// y = center.height + sin(endAngle) * (radius - 0);// } else {// x = center.width + cos(endAngle) * radius;// y = center.height + sin(endAngle) * radius;// }timeArray.add(Positioned(left: x,top: y,child: Transform.rotate(angle: endAngle,child: Container(width: i % 5 == 0 ? 8 : 6,height: 2,color: Colors.redAccent,),),),);}return timeArray;}Widget _pointerSecond() {return SizedBox(width: 120,height: 10,child: ClipPath(clipper: SecondPath(),child: Container(decoration: const BoxDecoration(color: Colors.red,),),),);}Widget _pointerMinute() {return SizedBox(width: 100,height: 15,child: ClipPath(clipper: MinutePath(),child: Container(decoration: const BoxDecoration(color: Colors.black54,),),),);}Widget _pointerHour() {return SizedBox(width: 80,height: 20,child: ClipPath(clipper: MinutePath(),child: Container(decoration: const BoxDecoration(color: Colors.black,),),),);}
}class SecondPath extends CustomClipper<Path> {@overridePath getClip(Size size) {var path = Path();path.moveTo(size.width / 3, 0);path.lineTo(size.width, size.height / 2);path.lineTo(size.width / 3, size.height);path.lineTo(0, size.height / 2);return path;}@overridebool shouldReclip(CustomClipper<Path> oldClipper) {return true;}
}class MinutePath extends CustomClipper<Path> {@overridePath getClip(Size size) {var path = Path();path.moveTo(0, size.height / 3);path.lineTo(size.width, size.height / 5 * 2);path.lineTo(size.width, size.height / 5 * 3);path.lineTo(0, size.height / 3 * 2);return path;}@overridebool shouldReclip(CustomClipper<Path> oldClipper) {return true;}
}