[Flutter]물리 동작 위젯

한상욱·2023년 2월 22일
0

Flutter 애니메이션

목록 보기
1/1
post-thumbnail

들어가며

이 포스팅은 flutter 공식사이트에 있는 튜토리얼 문서를 보고 직접 해보는 과정을 담았습니다.

물리 동작 위젯

사용자가 앱의 위젯을 드래그하면서 이동시키고, 위젯을 놓으면 제자리로 가는 그런 위젯을 만드는 예제입니다.

환경 구축

실습을 진행하기 위한 기본 환경을 먼저 구축하겠습니다. 새로운 프로젝트를 명령 팔레트를 이용해서 생성합니다. 기본으로 작성되어 있는 예제앱에서 main 함수와 MyApp클래스를 지우고, lib디렉터리 하위에 src > ui 순으로 디렉터리를 생성합니다. 그리고 저희가 MyApp에 전달하기 위한 App.dart를 생성합니다.

main.dart파일의 소스코드는 아래와 같습니다.

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: ,
    );
  }
}

Animation Controller 지정

이제는 MyApp클래스 MaterialApp의 home프로퍼티에 전달해줄 App.dart를 구성합니다. 그리고 home에는 아래를 전달합니다. 소스코드는 아래와 같습니다.

main.dart

...

  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: DraggableCard(
        child: const FlutterLogo(
          size: 128,
        ),
      ),
    );
  }

app.dart

import 'package:flutter/material.dart';

class DraggableCard extends StatefulWidget {
  final Widget child;
  const DraggableCard({required this.child});

  
  _DraggableCardState createState() => _DraggableCardState();
}

class _DraggableCardState extends State<DraggableCard> {
  
  void initState() {
    super.initState();
  }

  
  void dispose() {
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Align(
      child: Card(
        child: widget.child,
      ),
    );
  }
}

애니메이션 컨트롤러 선언

Animation을 위한 Controller를 지정할 건데, SingleTickerProviderStateMixin으로 클래스를 확장해서 AnimationController를 생성할겁니다. StatefulWidget하위의 _DraggableCardState클래스를 SingleTickerProviderStateMixin으로 확장해줍니다. 그리고 AnimationController인 _controller를 생성하고, initState()메소드를 이용해 초기화합니다.

...
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  
  void initState() {
    _controller =
        AnimationController(vsync: this, duration: const Duration(seconds: 1));
    super.initState();
  }
...

이렇게 AnimationController지정을 완료했습니다.

제스처를 이용한 위젯 이동

이번엔 제스처를 감지해서 위젯을 이동시키기 위한 작업을 해줄겁니다. 우선, Alignment를 클래스에 추가해줍니다. Alignment를 이용해서 위젯의 랜더위치를 전환시킬 겁니다. 이미지가 가운데로 갈 수 있도록 center정렬시킵니다.

...
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  Alignment _dragAlignment = Alignment.center;
  
  
  void initState() {
    _controller =
        AnimationController(vsync: this, duration: const Duration(seconds: 1));
    super.initState();
  }
  ...

이제 Alignment를 이미지에 적용시켜야겠죠. Align위젯의 alignment를 전달합니다.

...
return Align(
        alignment: _dragAlignment,
        child: Card(
          child: widget.child,
        ),
      ),
...

이제 유저의 손동작을 이미지가 인식할 수 있도록, GestureDetector로 이미지를 감싸줄겁니다. 리턴되는 위젯을 감싸줍니다.

...
return GestureDetector(
      child: Align(
        alignment: _dragAlignment,
        child: Card(
          child: widget.child,
        ),
      ),
    );
...

이제 선언한 Alignment를 조절하여 위젯을 움직이기 위해서는 위젯의 사이즈를 전달해줘야 합니다. MediaQuery를 이용해서 size를 가져올 수 있습니다.

...
var size = MediaQuery.of(context).size; // 이미지의 크기
return GestureDetector(
      child: Align(
        alignment: _dragAlignment,
        child: Card(
          child: widget.child,
        ),
      ),
    );
...

그리고 GestureDetector의 콜백처리 프로퍼티 중, onPanDown, onPanUpdate, onPanEnd를 선언해줍니다. 이 프로퍼티들은 detail 매개변수를 이용해서 동작을 조절할 수 있습니다.

...
var size = MediaQuery.of(context).size;
    return GestureDetector(
      onPanDown: (details) {},
      onPanUpdate: (details) {},
      onPanEnd: (details) {},
      child: Align(
        alignment: _dragAlignment,
        child: Card(
          child: widget.child,
        ),
      ),
    );
...

우선 onPanUpdate의 콜백함수로 setState를 전달하여, 화면을 재랜더할 것입니다.

...
var size = MediaQuery.of(context).size;
    return GestureDetector(
      onPanDown: (details) {},
      onPanUpdate: (details) {
      	setState(() {
          
        });
      },
      onPanEnd: (details) {},
      child: Align(
        alignment: _dragAlignment,
        child: Card(
          child: widget.child,
        ),
      ),
    );
...

setState에는 기존의 추가한 Alignment에 변화되는 양을 MediaQuery로 가져온 값의 크기의 절반으로 나눠주면, 좌표처럼 사용할 수 있습니다.

...
var size = MediaQuery.of(context).size;
    return GestureDetector(
      onPanDown: (details) {},
      onPanUpdate: (details) {
      	setState(() {
          _dragAlignment += Alignment( 
          //클릭하고 움직이는 방향에 따라 위젯의 정렬 위치를 조절함.
            details.delta.dx / (size.width / 2),
            details.delta.dy / (size.height / 2),
          );
        });
      },
      onPanEnd: (details) {},
      child: Align(
        alignment: _dragAlignment,
        child: Card(
          child: widget.child,
        ),
      ),
    );
...

그리고 저장을 하고, 이미지를 클릭해서 잡아당기면 움직입니다.

위젯 애니메이션 구성

자, 이미지는 이제 클릭으로 잡아당겨서 위치를 조절할 수 있습니다. 이제는 이미지 클릭을 해제하면, 다시 중앙으로 이동하는 것을 구현해야 합니다. 이런 애니메이션 효과를 주기위해서 Animation을 선언합니다.

...
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  Alignment _dragAlignment = Alignment.center;
  late Animation<Alignment> _animation;
  late AnimationController _controller;

  
  void initState() {
    super.initState();
    _controller =
...

생성한 애니메이션을 사용하기 위해서 _runAnimation() 메소드를 작성합니다.


...
  
  void dispose() {
    super.dispose();
  }

  void _runAnimation() { // 애니메이션을 사용하기 위한 메소드
    _animation = _controller.drive(
      AlignmentTween(
      // 현재 이미지 위치에서 다시 정중앙으로 이동시킴.
        begin: _dragAlignment,
        end: Alignment.center,
      ),
    );
    _controller.reset();
    _controller.forward();
  }

  
  Widget build(BuildContext context) {
    var size = MediaQuery.of(context).size;
    return GestureDetector(
      onPanDown: (details) {
        _controller.stop();
      },
      onPanUpdate: (details) {
     
...

이제 다음에는 뭘해야 할지 슬슬 보이시죠? 이미지를 잡아당기는 것이 종료되는 시점에 이 애니메이션을 실행시키면, 제자리로 돌아갈겁니다. GestureDetector의 onPanEnd의 콜백함수로 전달해줍시다.

...
  
  Widget build(BuildContext context) {
    var size = MediaQuery.of(context).size;
    return GestureDetector(
      onPanDown: (details) {
        _controller.stop();
      },
      onPanUpdate: (details) {
        setState(() {
          _dragAlignment += Alignment(
            details.delta.dx / (size.width / 2),
            details.delta.dy / (size.height / 2),
          );
        });
      },
      onPanEnd: (details) {
        _runAnimation();
      },
...

이제 컨트롤러의 addListener를 이용해서, 애니메이션으로 발생하는 위치값을 갱신할 수 있도록 처리합니다. addListener메소드에 setState를 이용해서 전달할 수 있겠죠?

...
  
  void initState() {
    super.initState();
    _controller =
        AnimationController(vsync: this, duration: const Duration(seconds: 1));
    _controller.addListener(() {
      setState(() {
        _dragAlignment = _animation.value;
      });
    });
  }
...

마지막으로, GestureDetector가 애니메이션을 관리할 수 있도록, onPanDown의 콜백에 _controller.stop()을 전달합니다.

...
  
  Widget build(BuildContext context) {
    var size = MediaQuery.of(context).size;
    return GestureDetector(
      onPanDown: (details) {
        _controller.stop();
      },
      onPanUpdate: (details) {
        setState(() {
          _dragAlignment += Alignment(
            details.delta.dx / (size.width / 2),
            details.delta.dy / (size.height / 2),
          );
        });
      },
      onPanEnd: (details) {
        _runAnimation();
      },
...

이제 이미지를 이동시킨 후 커서를 풀면, 이미지가 제자리로 돌아갑니다.

실제 돌아가는 속도 구현

지금 애니메이션은 이미지를 빠르게 끌어당기거나, 느리게 끌어당기거나 무조건 동일한 속도로 돌아갑니다. 하지만, 현실적인 바운스 애니메이션을 구성하기 위해서 실제 속도를 계산해야 합니다.

이를 위해서 app.dart파일 상단에 physics패키지를 임포트합니다.

import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';

class DraggableCard extends StatefulWidget {
  final Widget child;
  const DraggableCard({required this.child});

  
  _DraggableCardState createState() => _DraggableCardState();
}

class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
    ...

구현하기 이전에, onPanEnd의 콜백메소드는 DragAndDetail 객체를 제공하는데, 이 객체가 스크린에서 터치를 종료하는 시점에 속도를 반환해줍니다. 여기서 중요한점이 앱에서의 속도는 초당 픽셀인데, Align은 속도가 아니라 좌표를 사용한다는 점입니다. 그래서, 속도를 좌표로 전환해주어 전달하면, 거리가 멀수록 빠르게 제자리를 찾아가겠죠?

우선, _runAnimation 메소드의 파라미터를 생성해서, 속도와 사이즈를 전달하겠습니다. 그리고 이 메소드 안에서 값을 계산해서 전달해야 위젯이 현실적으로 제자리로 돌아갈겁니다.

void _runAnimation(Offset pixelsPerSecond, Size size) {
    _animation = _controller.drive(
      AlignmentTween(
        begin: _dragAlignment,
        end: Alignment.center,
      ),
    );

    //애니메이션 컨트롤러를 이용해서 좌표값으로 전환
    final unitsPerSecondX = pixelsPerSecond.dx / size.width; 
    final unitsPerSecondY = pixelsPerSecond.dy / size.height;
    final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
    final unitVelocity = unitsPerSecond.distance;

    const spring = SpringDescription(
      mass: 30,
      stiffness: 1,
      damping: 1,
    );

    final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);

    _controller.animateWith(simulation);
  }

자, 이제 완성입니다. 저장을 하면 고무줄같은 물리적 애니메이션을 구현하는 이미지가 완성되었습니다.

전체 소스 코드

https://github.com/SangWook16074/flutter_animation

profile
개발공부를 기록하자

0개의 댓글