Technology Sharing

Flutter-Realize the physical ball collision effect

2024-07-12

한어Русский языкEnglishFrançaisIndonesianSanskrit日本語DeutschPortuguêsΕλληνικάespañolItalianoSuomalainenLatina

Effect

insert image description here

introduction

Implementing physical animation effects in Flutter applications can greatly improve the user experience. This article will introduce in detail how to create an animated ball interface that simulates physical collisions in Flutter. The main code is based on the integrationsensors_plusPlugin to get the device's accelerometer sensor data.

Preparation

Before you begin, make sure you havepubspec.yamlAdd to the filesensors_plusPlugins:

dependencies:
  flutter:
    sdk: flutter
  sensors_plus: 4.0.2
  • 1
  • 2
  • 3
  • 4

Then runflutter pub getcommand to get dependencies.

Code structure

We will implement aPhysicsBallWidgetThe custom widget mainly includes the following parts:

  • Ball class: represents the basic information of each ball.
  • BadgeBallConfig class: manages the state and behavior of each ball.
  • PhysicsBallWidget class: The main widget, containing the ball's logic and animation.
  • BallItemWidget class: The widget that displays each ball.
  • BallListPage class: test page, showing the physical ball animation effect.

Ball Class

First defineBallClass, used to represent basic information of each ball, such as its name:

class Ball {
  final String name;

  Ball({required this.name});
}
  • 1
  • 2
  • 3
  • 4
  • 5

BadgeBallConfig Class

BadgeBallConfigThe class is used to manage the state and behavior of each ball, including acceleration, speed, position and other information:

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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85

PhysicsBallWidget Class

PhysicsBallWidgetThe class is the main component, responsible for handling the logic and animation of the ball:

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];
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163

BallItemWidget Class

BallItemWidgetThe class is used to display each ball and handle its animation and events:

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));
    });
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157

BallListPage Class

BallListPageThe class is a test page, which is used to show the physical ball animation effect:

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,
          ),
        ],
      ),
    );
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45

in conclusion

Through this blog, we show how to implement a physical ball animation effect in Flutter and integratesensors_plusPlugin to obtain the device's accelerometer data. I hope this blog can help you achieve similar effects in Flutter development.
For more information, see: github.com/yixiaolunhui/flutter_xy