쉽고 편한 공간
[Flutter] 소주병 돌리기 앱 구현 본문
MT 등에서 술병을 슥 돌려 술병이 가리키는 사람이 벌칙을 받는 식의 놀이가 있다. 소주병이 은근히 잘 돌아가 돌리는 손맛이 있다. 현재 제작중인 랜덤 술게임 앱에서 이 술병 돌리기 게임을 구현해보고자 한다.
특정 child를 rotate 시키는 위젯은 존재하지만, 내가 원하는 기능 :
1. 화면을 터치중일때는 터치한 지점으로 술병이 자동 회전
2. 술병을 돌리는 모션을 취할 경우 일정 시간동안 술병이 회전하다 자연스럽게 감속하여 정지
을 구현해놓은 라이브러리는 없었다.
이에 내가 찾은 방법은 GestureDetector 위젯과 AnimatedBuilder 위젯을 활용하는 것이다.
GestureDetector은 화면을 터치할때 상세한 상호작용을 정의할 수 있도록 하는 위젯이다.
AnimatedBuilder는 시간에 따라 동적인 animation을 위젯에 부여할 수 있도록 한다.
설계는 다음과 같다.
<화면을 터치중인 경우>
1. GestureDetector을 이용해 터치한 좌표를 실시간으로 변수에 저장한다.
double startDXPoint = 0;
double startDYPoint = 0;
return GestureDetector(
onHorizontalDragStart: _onDragStartHandler,
onVerticalDragStart: _onDragStartHandler,
behavior: HitTestBehavior.translucent,
onHorizontalDragUpdate: _onDragUpdateHandler,
onVerticalDragUpdate: _onDragUpdateHandler,
child : ...
void _onDragStartHandler(DragStartDetails details) {
setState(() {
this.startDXPoint =
double.parse(details.globalPosition.dx.toStringAsFixed(3));
this.startDYPoint =
double.parse(details.globalPosition.dy.toStringAsFixed(3));
});
}
void _onDragUpdateHandler(DragUpdateDetails details) {
setState(() {
this.startDXPoint =
double.parse(details.globalPosition.dx.toStringAsFixed(3));
this.startDYPoint =
double.parse(details.globalPosition.dy.toStringAsFixed(3));
});
}
2. 좌표를 표준에 맞게 변환해주고 arctan 함수를 이용해 회전각 angle을 정의한다.
double width = MediaQuery.of(context).size.width;
double height = MediaQuery.of(context).size.height;
Vector2 position =
Vector2(startDXPoint - width / 2, height / 2 - startDYPoint);
angle = position.x > 0
? atan(position.y / position.x)
: pi + atan(position.y / position.x);
이때 angle을 x좌표가 음수일때 pi를 더해준 이유는 arctan의 치역이 -pi/2~pi/2이므로 한바퀴 도는것을 표현하기 위해서는 pi의 위상을 보정해주어야 하기 때문이다. 또, 좌표를 변환한 이유는 flutter에서는 화면의 맨 왼쪽 위를 (0,0)으로 놓고 오른쪽, 아래쪽으로 갈수록 + 값을 갖도록 좌표가 설정되어있다. 때문에 계산의 편리를 위해 좌표를 통일해주었다.
3. AnimatorBuilder위젯의 child로 Transform.rotate를 주고 angle 인자로 위에서 구한 angle을 준다.
AnimatedBuilder(
animation: _controller,
child: (...),
builder: (BuildContext context, Widget _widget) {
return new Transform.rotate(
angle: angle,
child: _widget,
);
},
),
이때 _controller는 초기에 선언된 AnimationController 타입의 변수다. child에는 회전시키고픈 위젯이 들어간다. 최종적으로 우리는 소주병 이미지 에셋을 child로 취하게 될 것이다.
위와 같이 설정하면 화면을 터치할때 터치한 방향의 중심각 만큼 child가 회전하여 병을 조종하는 듯한 효과를 얻는다.
<병을 돌리는 모션을 취한 이후>
조금 더 구체적으로는 화면을 드래그하다 드래그가 멈추는 시점 이후의 병의 회전을 구현해야 한다.
이 회전을 설계하기 위해선 이론적 접근이 필요했다.
드래그가 특정 지점에서 끝나고 그 지점에서의 드래그 속도가 측정된다고 하자. 그 속도벡터는 소주병이 회전하는 접선 방향과 나란하지 않을 수 있다. 때문에 그 접선 벡터를 구하기 위해 일련의 과정이 필요하다.
1. 드래그가 끝난 순간 드래깅 속도 측정
Vector2 vel = Vector2.all(0);
... //GestureDetector constructor
onHorizontalDragEnd: _onDragEnd,
onVerticalDragEnd: _onDragEnd,
...
void _onDragEnd(DragEndDetails details) {
double vel_x = details.velocity.pixelsPerSecond.dx.floorToDouble();
double vel_y = -details.velocity.pixelsPerSecond.dy.floorToDouble();
setState(() {
this.vel = Vector2(vel_x, vel_y);
});
}
2. 접선 속도 벡터 계산
Vector2 tanVel =
vel - position * dot2(vel, position) / dot2(position, position);
접선벡터의 크기가 곧 소주병이 회전하는 초기 각속도 w0가 된다.
회전하는 방향의 반대 방향으로 일정한 각가속도 alpha가 작용한다고 하자.
그리고 미리 설정한 _duration 초 동안만 회전하고 딱 정지하도록 alpha를 역산한다.
bool direction = cross2(position, tanVel) > 0;
double w0 = (direction ? 1 : -1) *
sqrt(dot2(tanVel, tanVel)) /
sqrt(dot2(position, position));
double alpha = -w0 / _duration;
회전방향은 접선벡터와 위치벡터를 외적한 부호로 구별했고, 방향에따라 w0의 부호로 달리 주었다.
이제 시간에 따라 회전한 각이 얼마인지만 계산하여 적용해주면 된다.
고교 물리 회전운동 파트에 나오는 내용으로 충분하다.
theta = w0*t + 1/2*alpha*t^2+theta0
이때 t의 역할을 _controller.value가 하게 된다. 정확히는, duration 동안 _controller.value는 0에서 1까지 값이 linear하게 상승한다. 따라서 duration을 곱해주면 정확히 t의 역할을 한다. 식을 잘 정리하여 대입하면 다음과 같다.
return new Transform.rotate(
angle: isDragging
? -angle
: -angle +
alpha *
_duration *
_duration *
_controller.value *
(1 - 0.5 * _controller.value),
child: _widget,
);
isDragging은 손이 화면에 닿아있는지 여부를 체크하는 변수다.
이후 _controller 관련 조작 설정만 조금 추가해주면 코드가 완성된다.
최종 코드는 다음과 같다.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'dart:math';
import 'package:vector_math/vector_math.dart' hide Colors;
class BottleSpinner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: SafeArea(
child: Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
child: Main(),
),
),
),
);
}
}
class Main extends StatefulWidget {
@override
_MainState createState() => _MainState();
}
class _MainState extends State<Main> with SingleTickerProviderStateMixin {
bool isDragging = false;
double startDXPoint = 0;
double startDYPoint = 0;
double angle = 0;
Vector2 vel = Vector2.all(0);
int _duration = 5;
String imgsrc = 'images/alcohol_bottle.png';
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = new AnimationController(
vsync: this,
duration: new Duration(seconds: _duration),
);
_controller.repeat();
}
@override
Widget build(BuildContext context) {
double width = MediaQuery.of(context).size.width;
double height = MediaQuery.of(context).size.height;
Vector2 position =
Vector2(startDXPoint - width / 2, height / 2 - startDYPoint);
angle = position.x > 0
? atan(position.y / position.x)
: pi + atan(position.y / position.x);
Vector2 tanVel =
vel - position * dot2(vel, position) / dot2(position, position);
bool direction = cross2(position, tanVel) > 0;
double w0 = (direction ? 1 : -1) *
sqrt(dot2(tanVel, tanVel)) /
sqrt(dot2(position, position));
double alpha = -w0 / _duration;
if (_controller.value == 1) _controller.stop(); //한번 멈춘경우 다시 돌지 못하게
return GestureDetector(
onHorizontalDragStart: _onDragStartHandler,
onVerticalDragStart: _onDragStartHandler,
behavior: HitTestBehavior.translucent,
onHorizontalDragUpdate: _onDragUpdateHandler,
onVerticalDragUpdate: _onDragUpdateHandler,
onHorizontalDragEnd: _onDragEnd,
onVerticalDragEnd: _onDragEnd,
child: Center(
child: Stack(children: <Widget>[
Container(
height: height,
width: width,
),
Align(
alignment: Alignment.center,
child: AnimatedBuilder(
animation: _controller,
child: Container(
child: Image.asset(imgsrc),
),
builder: (BuildContext context, Widget _widget) {
return new Transform.rotate(
angle: isDragging
? -angle
: -angle +
alpha *
_duration *
_duration *
_controller.value *
(1 - 0.5 * _controller.value),
child: _widget,
);
},
),
),
]),
),
);
}
void _onDragStartHandler(DragStartDetails details) {
_controller.stop();
setState(() {
this.isDragging = true;
this.startDXPoint =
double.parse(details.globalPosition.dx.toStringAsFixed(3));
this.startDYPoint =
double.parse(details.globalPosition.dy.toStringAsFixed(3));
});
}
void _onDragUpdateHandler(DragUpdateDetails details) {
setState(() {
this.startDXPoint =
double.parse(details.globalPosition.dx.toStringAsFixed(3));
this.startDYPoint =
double.parse(details.globalPosition.dy.toStringAsFixed(3));
});
}
void _onDragEnd(DragEndDetails details) {
double vel_x = details.velocity.pixelsPerSecond.dx.floorToDouble();
double vel_y = -details.velocity.pixelsPerSecond.dy.floorToDouble();
_controller.value = 0;
_controller.forward();
setState(() {
this.isDragging = false;
this.vel = Vector2(vel_x, vel_y);
});
}
}
실행시켜보면 아주 부드럽게 작동한다.
좀더 보완하려면 드래그 속도에 따라 회전 duration을 조정하면 될듯 한데 정확한 상관관계는 물리적으로 계산해봐야 알 듯 하다. 그래도 5초로 설정하여도 부자연스럽지 않으므로 사용자 옵션으로 duration을 설정토록 할 수 도 있을 것이다.