效果
引言
在Flutter应用中实现物理动画效果,可以大大提升用户体验。本文将详细介绍如何在Flutter中创建一个模拟物理碰撞的动画小球界面,主要代码实现基于集成sensors_plus
插件来获取设备的加速度传感器数据。
准备工作
在开始之前,请确保在pubspec.yaml
文件中添加sensors_plus
插件:
dependencies:
flutter:
sdk: flutter
sensors_plus: 4.0.2
然后运行flutter pub get
命令来获取依赖。
代码结构
我们将实现一个名为PhysicsBallWidget
的自定义小部件,主要包含以下几部分:
- Ball类:表示每个球的基本信息。
- BadgeBallConfig类:管理每个球的状态和行为。
- PhysicsBallWidget类:主部件,包含球的逻辑和动画。
- BallItemWidget类:具体显示每个球的小部件。
- BallListPage类:测试页面,展示物理球动画效果。
Ball类
首先定义Ball
类,用于表示每个球的基本信息,例如名称:
class Ball {
final String name;
Ball({required this.name});
}
BadgeBallConfig类
BadgeBallConfig
类用于管理每个球的状态和行为,包括加速度、速度、位置等信息:
class BadgeBallConfig {
final Acceleration _acceleration = Acceleration(0, 0);
final double time = 0.02;
late Function(Offset) collusionCallback;
Size size = const Size(100, 100);
Speed _speed = Speed(0, 0);
late Offset _position;
late String name;
double oppositeAccelerationCoefficient = 0.7;
void setPosition(Offset offset) {
_position = offset;
}
void setInitSpeed(Speed speed) {
_speed = speed;
}
void setOppositeSpeed(bool x, bool y) {
if (x) {
_speed.x = -_speed.x * oppositeAccelerationCoefficient;
if (_speed.x.abs() < 5) _speed.x = 0;
}
if (y) {
_speed.y = -_speed.y * oppositeAccelerationCoefficient;
if (_speed.y.abs() < 5) _speed.y = 0;
}
}
void setAcceleration(double x, double y) {
_acceleration.x = x * oppositeAccelerationCoefficient;
_acceleration.y = y * oppositeAccelerationCoefficient;
}
Speed getCurrentSpeed() => _speed;
Offset getCurrentCenter() => Offset(
_position.dx + size.width / 2,
_position.dy + size.height / 2,
);
Offset getCurrentPosition() => _position;
void inertiaStart(double x, double y) {
if (x.abs() > _acceleration.x.abs()) _speed.x += x;
if (y.abs() > _acceleration.y.abs()) _speed.y += y;
}
void afterCollusion(Offset offset, Speed speed) {
_speed = Speed(
speed.x * oppositeAccelerationCoefficient,
speed.y * oppositeAccelerationCoefficient,
);
_position = offset;
collusionCallback(offset);
}
Offset getOffset() {
var offsetX = (_acceleration.x.abs() < 5 && _speed.x.abs() < 3) ? 0.0 : _speed.x * time + (_acceleration.x * time * time) / 2;
var offsetY = (_acceleration.y.abs() < 5 && _speed.y.abs() < 6) ? 0.0 : _speed.y * time + (_acceleration.y * time * time) / 2;
_position = Offset(_position.dx + offsetX, _position.dy + offsetY);
_speed = Speed(
_speed.x + _acceleration.x * time,
_speed.y + _acceleration.y * time,
);
return _position;
}
}
class Speed {
double x;
double y;
Speed(this.x, this.y);
}
class Acceleration {
double x;
double y;
Acceleration(this.x, this.y);
}
PhysicsBallWidget类
PhysicsBallWidget
类是主部件,负责处理球的逻辑和动画:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_xy/application.dart';
import 'package:flutter_xy/xydemo/ball/ball_model.dart';
import 'package:sensors_plus/sensors_plus.dart';
//https://github.com/yixiaolunhui/flutter_xy
class PhysicsBallWidget extends StatefulWidget {
final List<Ball> ballList;
final double height;
final double width;
const PhysicsBallWidget({
required this.ballList,
required this.width,
required this.height,
Key? key,
}) : super(key: key);
State<StatefulWidget> createState() => _PhysicsBallState();
}
class _PhysicsBallState extends State<PhysicsBallWidget> {
List<Widget> badgeBallList = [];
List<ValueKey<BadgeBallConfig>> keyList = [];
late Size ballSize;
void initState() {
super.initState();
fillKeyList();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
App.get().addPersistentFrameCallback(travelHitMap);
});
}
void dispose() {
App.get().removePersistentFrameCallback(travelHitMap);
super.dispose();
}
Widget build(BuildContext context) {
fillWidgetList();
return Stack(
children: badgeBallList,
);
}
void fillKeyList() {
var badgeSize = (widget.width - 20) / 6;
badgeSize = (badgeSize >= 84.0 || badgeSize <= 0.0 || !badgeSize.isFinite)
? 84.0
: badgeSize;
var maxCount = ((widget.height - badgeSize) ~/ badgeSize) *
(widget.width ~/ badgeSize);
if (widget.ballList.length >= maxCount) {
badgeSize = 50.0;
}
ballSize = Size(badgeSize, badgeSize);
var initOffsetX = 0.0;
var initOffsetY = widget.height - badgeSize;
for (var element in widget.ballList) {
keyList.add(ValueKey<BadgeBallConfig>(
BadgeBallConfig()
..size = ballSize
..name = element.name
..setPosition(Offset(initOffsetX, initOffsetY)),
));
initOffsetX += badgeSize;
if (initOffsetX + badgeSize > widget.width - 20) {
initOffsetX = 0;
initOffsetY -= badgeSize;
}
}
}
void fillWidgetList() {
badgeBallList.clear();
for (var e in keyList) {
badgeBallList.add(
BallItemWidget(
key: e,
limitWidth: widget.width,
limitHeight: widget.height,
onTap: () {},
),
);
}
}
void travelHitMap(Duration timeStamp) {
for (var i = 0; i < keyList.length - 1; i++) {
for (var j = i + 1; j < keyList.length; j++) {
hit(keyList[i].value, keyList[j].value);
}
}
}
void hit(BadgeBallConfig a, BadgeBallConfig b) {
final distance = a.size.height / 2 + b.size.height / 2;
final w = b.getCurrentCenter().dx - a.getCurrentCenter().dx;
final h = b.getCurrentCenter().dy - a.getCurrentCenter().dy;
if (sqrt(w * w + h * h) <= distance) {
var aOriginSpeed = a.getCurrentSpeed();
var bOriginSpeed = b.getCurrentSpeed();
var aOffset = a.getCurrentPosition();
var angle = atan2(h, w);
var sinNum = sin(angle);
var cosNum = cos(angle);
var aCenter = [0.0, 0.0];
var bCenter = coordinateTranslate(w, h, sinNum, cosNum, true);
var aSpeed = coordinateTranslate(
aOriginSpeed.x, aOriginSpeed.y, sinNum, cosNum, true);
var bSpeed = coordinateTranslate(
bOriginSpeed.x, bOriginSpeed.y, sinNum, cosNum, true);
var vxTotal = aSpeed[0] - bSpeed[0];
aSpeed[0] = (2 * 10 * bSpeed[0]) / 20;
bSpeed[0] = vxTotal + aSpeed[0];
var overlap = distance - (aCenter[0] - bCenter[0]).abs();
aCenter[0] -= overlap;
bCenter[0] += overlap;
var aRotatePos =
coordinateTranslate(aCenter[0], aCenter[1], sinNum, cosNum, false);
var bRotatePos =
coordinateTranslate(bCenter[0], bCenter[1], sinNum, cosNum, false);
var bOffset
X = aOffset.dx + bRotatePos[0];
var bOffsetY = aOffset.dy + bRotatePos[1];
var aOffsetX = aOffset.dx + aRotatePos[0];
var aOffsetY = aOffset.dy + aRotatePos[1];
var aSpeedF =
coordinateTranslate(aSpeed[0], aSpeed[1], sinNum, cosNum, false);
var bSpeedF =
coordinateTranslate(bSpeed[0], bSpeed[1], sinNum, cosNum, false);
a.afterCollusion(
Offset(aOffsetX, aOffsetY), Speed(aSpeedF[0], aSpeedF[1]));
b.afterCollusion(
Offset(bOffsetX, bOffsetY), Speed(bSpeedF[0], bSpeedF[1]));
}
}
List<double> coordinateTranslate(
double x, double y, double sin, double cos, bool reverse) {
return reverse
? [x * cos + y * sin, y * cos - x * sin]
: [x * cos - y * sin, y * cos + x * sin];
}
}
BallItemWidget类
BallItemWidget
类用于具体显示每个球,并处理其动画和事件:
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_xy/application.dart';
import 'package:flutter_xy/xydemo/ball/ball_model.dart';
import 'package:sensors_plus/sensors_plus.dart';
class BallItemWidget extends StatefulWidget {
final double limitWidth;
final double limitHeight;
final Function onTap;
const BallItemWidget({
required this.limitWidth,
required this.limitHeight,
required this.onTap,
Key? key,
}) : super(key: key);
State<StatefulWidget> createState() => BallItemState();
}
class BallItemState extends State<BallItemWidget> {
final List<StreamSubscription<dynamic>> _streamSubscriptions = [];
late BadgeBallConfig config;
Duration sensorInterval = SensorInterval.normalInterval;
var color = Color.fromARGB(
255,
Random().nextInt(256),
Random().nextInt(256),
Random().nextInt(256),
);
Timer? timer;
double x = 0;
double y = 0;
double limitY = 0;
double limitX = 0;
void initState() {
super.initState();
initData();
_streamSubscriptions.add(
accelerometerEvents.listen(
(AccelerometerEvent event) {
config.setAcceleration(
-double.parse(event.x.toStringAsFixed(1)) * 50,
double.parse(event.y.toStringAsFixed(1)) * 50,
);
},
),
);
_streamSubscriptions.add(
userAccelerometerEvents.listen(
(UserAccelerometerEvent event) {
config.inertiaStart(
double.parse(event.x.toStringAsFixed(1)) * 50,
-double.parse(event.y.toStringAsFixed(1)) * 20,
);
},
),
);
timer = Timer.periodic(const Duration(milliseconds: 20), (timer) {
if (!SchedulerBinding.instance.hasScheduledFrame) {
SchedulerBinding.instance.scheduleFrame();
}
});
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
App.get().addPersistentFrameCallback(updatePosition);
});
}
void dispose() {
super.dispose();
for (var subscription in _streamSubscriptions) {
subscription.cancel();
}
App.get().removePersistentFrameCallback(updatePosition);
timer?.cancel();
timer = null;
}
Widget build(BuildContext context) {
return AnimatedPositioned(
left: x,
top: y,
duration: const Duration(milliseconds: 16),
child: GestureDetector(
onTap: () {
widget.onTap.call();
},
child: Container(
width: config.size.width,
alignment: Alignment.center,
height: config.size.height,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: color, width: 2.w),
),
child: Text(
config.name,
style: TextStyle(fontSize: 16.w, color: Colors.red),
),
),
),
);
}
void initData() {
limitX = widget.limitWidth;
limitY = widget.limitHeight;
config = (widget.key as ValueKey<BadgeBallConfig>).value;
config.collusionCallback = (offset) {
setState(() {
x = offset.dx;
y = offset.dy;
config.setPosition(offset);
});
};
x = config.getCurrentPosition().dx;
y = config.getCurrentPosition().dy;
}
void updatePosition(Duration timeStamp) {
setState(() {
var tempX = config.getOffset().dx;
var tempY = config.getOffset().dy;
if (tempX < 0) {
tempX = 0;
config.setOppositeSpeed(true, false);
}
if (tempX > limitX - config.size.width) {
tempX = limitX - config.size.width;
config.setOppositeSpeed(true, false);
}
if (tempY < 0) {
tempY = 0;
config.setOppositeSpeed(false, true);
}
if (tempY > limitY - config.size.height) {
tempY = limitY - config.size.height;
config.setOppositeSpeed(false, true);
}
x = tempX;
y = tempY;
config.setPosition(Offset(x, y));
});
}
}
BallListPage类
BallListPage
类是测试页面,用于展示物理球动画效果:
import 'package:flutter/material.dart';
import 'package:flutter_xy/xydemo/ball/ball_model.dart';
import 'package:flutter_xy/xydemo/ball/ball_widget.dart';
class BallListPage extends StatefulWidget {
const BallListPage({super.key});
State<BallListPage> createState() => _BallListPageState();
}
class _BallListPageState extends State<BallListPage> {
final List<Ball> badgeList = [
Ball(name: '北京'),
Ball(name: '上海'),
Ball(name: '天津'),
Ball(name: '徐州'),
Ball(name: '南京'),
Ball(name: '苏州'),
Ball(name: '杭州'),
Ball(name: '合肥'),
Ball(name: '武汉'),
Ball(name: '常州'),
Ball(name: '香港'),
Ball(name: '澳门'),
Ball(name: '新疆'),
Ball(name: '成都'),
Ball(name: '宿迁'),
];
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
PhysicsBallWidget(
ballList: badgeList,
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
),
],
),
);
}
}
结论
通过这篇博客,我们展示了如何在Flutter中实现一个物理球动画效果,并且集成了sensors_plus
插件来获取设备的加速度传感器数据。希望这篇博客能对您在Flutter开发中实现类似效果有所帮助。
详情见:github.com/yixiaolunhui/flutter_xy