Notice
Recent Posts
Recent Comments
Link
관리 메뉴

쉽고 편한 공간

[Flutter] 소주병 돌리기 앱 구현 본문

개발 일지[폐쇄]

[Flutter] 소주병 돌리기 앱 구현

evera_fter 2020. 8. 27. 17:06

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을 설정토록 할 수 도 있을 것이다. 

Comments