Flutter

[UI] 증기 (Steam) 만들기

모리선생 2023. 4. 21. 23:26
728x90

목표

증기 형태를 만들어 보고 수치를 변형해보면서 내가 원하는 모양의 증기를 만들어본다.


이번 시리즈는 내가 나중에라도 한번쯤은 써보고 싶어서 기록차 적어놓는 포스트가 될 듯 하다. 코드는 도그풋님의 영상 'ASMR-Steam animation'을 참고하였으며, 본인 개인의 창작물임이 아님을 다시 한번 밝힌다 (도그풋님의 영상을 보면서 느끼는거지만 정말 코딩을 잘하신다는 생각을 한다. 연습해야지.)

 

그럼 코드 시작!

 

1. Dependency

없음

 

2. 파일준비

ㄴ main.dart : 만들어진 UI를 보여줄 '그릇'을 생성한다고 보면된다.

ㄴ widgets(폴더)

    ㄴsteam_page.dart : One steam (하나의증기)를 겹겹히 나타내기 위해 SteamPage 위젯을 구성하였다.

    ㄴone_steam.dart : One steam (하나의증기)의 상승, blur, 무작위성 등을 표현하기 위한 코드이다.

 

3. 파일생성

3-1. main.dart 

이부분이야 뭐. 초보인 우리가 몇번이고 지웠다 썼다를 반복하는 부분이니 자세한 설명은 하지 않겠다. 간단하게 증기 효과들이 담길 액자들을 만들었다.

import 'package:flutter/material.dart';
import 'package:flutter_steam/widgets/steam_page.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    Widget child = const SteamPage();
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        backgroundColor: Colors.black,
        body: Container(
          margin: const EdgeInsets.all(20),
          decoration: BoxDecoration(
            color: Colors.black,
            borderRadius: BorderRadius.circular(10),
          ),
          child: child,
        ),
      ),
    );
  }
}

 

3-2. steam_page.dart

해당 파일의 경우에는 One steam이 여러개의 steam으로써 상승하는 효과를 보여주도록 여러개를 List의 형태로 계속 보여주는 기능을 구현하는 코드라고 생각하면 쉬울듯하다. 여기서 var padding = constraints.maxWidth / 8은 위젯의 너비에 대한 패딩값을 계산하는 코드로써 계산된 패딩값을 통해 OneSteam 위젲의 sidepadding으로써 사용을 한다. 이로써 증기효과를 좌우 padding 값을 설정한다. screen에서 보면 Size를 maxWidth에서 padding*2의 값을 제한 뒤에 screen의 값을 도출하는 것을 확인 할 수 있다.

import 'package:flutter/material.dart';
import 'package:flutter_steam/widgets/one_steam.dart';

class SteamPage extends StatelessWidget {
  const SteamPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
          var padding = constraints.maxWidth / 8;
          return Stack(
              children: List.generate(
                  OneSteam.maxSteamNumber,
                  (index) => OneSteam(
                      index: index,
                      screen: Size(constraints.maxWidth - padding * 2,
                          constraints.maxHeight),
                      sidePadding: padding)));
        },
      ),
    );
  }
}

 

3-3. one_steam.dart

전체코드는 다음과 같으며, 구역별로 설명하자면 각각의 기능은 다음과 같다.

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

class OneSteam extends StatefulWidget {
  static const int maxSteamNumber = 50;
  const OneSteam(
      {Key? key,
      required this.index,
      required this.screen,
      required this.sidePadding})
      : super(key: key);
  final int index;
  final Size screen;
  final double sidePadding;

  @override
  _OneSteamState createState() => _OneSteamState();
}

class _OneSteamState extends State<OneSteam>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _animation;

  @override
  void initState() {
    _controller = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 1200));
    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
    _controller.addListener(() {
      setState(() {});
    });
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        _controller.reset();
        Future.delayed(
            Duration(milliseconds: (800 * Random().nextDouble()).floor()),
            () => _controller.forward());
      }
    });
    Future.delayed(
        Duration(milliseconds: (2800 * Random().nextDouble()).floor()),
        () => _controller.forward());
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  double get minimumSteamWidth =>
      (widget.screen.width - widget.sidePadding * 2) / OneSteam.maxSteamNumber;

  @override
  Widget build(BuildContext context) {
    var circleSize = (widget.screen.width - widget.sidePadding * 2) / 4;
    var circleWidth = (minimumSteamWidth + (circleSize - minimumSteamWidth)) *
        _animation.value;
    return Positioned(
      left: widget.sidePadding +
          (widget.index * minimumSteamWidth) -
          circleWidth / 2 +
          widget.sidePadding / 2,
      bottom: (widget.screen.height - circleSize) * _animation.value,
      child: SizedBox(
        width: circleWidth,
        height: circleSize,
        child: CustomPaint(
          painter: OneSteamPainter(1 - _animation.value),
        ),
      ),
    );
  }
}

class OneSteamPainter extends CustomPainter {
  OneSteamPainter(this.opacity);
  final double opacity;

  @override
  void paint(Canvas canvas, Size size) {
    var p = Paint();
    p.color = Colors.white.withOpacity(opacity);
    p.maskFilter = const MaskFilter.blur(BlurStyle.normal, 50);

    canvas.drawOval(Rect.fromLTWH(0, 0, size.width, size.height), p);
  }

  @override
  bool shouldRepaint(covariant CustomPainter coldDelegate) {
    return true;
  }
}

 

3-3-1. 매개변수의 설정

여러개의 OneSteam 위젯이 함께 나열되어 스팀 효과를 나타낼 수 있도록, 세가지의 매개변수를 설정한다. Index, screen, sidePadding은 각각 위젯의 위치, 앱 화면의 크기, 앱 화면의 양쪽 가장자리 스팀이 시작되는 거리를 나타낸다. 그리고 최대 스팀의 갯수는 maxSteamNumber로 저장된다.

class OneSteam extends StatefulWidget {
  static const int maxSteamNumber = 50;
  const OneSteam(
      {Key? key,
      required this.index,
      required this.screen,
      required this.sidePadding})
      : super(key: key);
  final int index;
  final Size screen;
  final double sidePadding;

  @override
  _OneSteamState createState() => _OneSteamState();
}

 

3-3-2. _OneSteamState 클래스

_OneSteamState 클래스를 정의하며 위젯상태를 관리한다.

class _OneSteamState extends State<OneSteam>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _animation;

 

3-3-3. 초기화 initState()

해당 부분은 초기화를 담당하지만 3가지로 나누어볼 수 있는데,

(1) AnimatioController와 Tween 객체를 생성하고 addListener()를 등록

(2) addStatusListener를 통해 애니메이션 재생이 완료가 된 경우 임의의 시간 (800ms * random...)값을 정수변환 한 뒤 forward()로 다시 실행시키는 기능

(3) 애니메이션을 실행을 할때 일정기간 (2800ms * random...)값을 정수변환 한뒤 실행하는 기능

으로 구성되어 있다.

@override
  void initState() {
    _controller = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 1200));
    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
    _controller.addListener(() {
      setState(() {});
    });
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        _controller.reset();
        Future.delayed(
            Duration(milliseconds: (800 * Random().nextDouble()).floor()),
            () => _controller.forward());
      }
    });
    Future.delayed(
        Duration(milliseconds: (2800 * Random().nextDouble()).floor()),
        () => _controller.forward());
    super.initState();
  }

 

3-3-4. 증기의 모양과 크기 그리고 위치 정하기

해당 코드는 증기의 크기, 위치, 그리고 최소 증기 너비등을 계산하기 위해 만들어진 코드이다. minimumSteamWidth로 증기 한개당의 크기를 double로 확인 하고, 이를 기반으로 circlesize와 circleWidth를 계산한다. 

그 밑에 Positioned를 통해서 부모 위젯 내에서 자식 위젯을 배치하는 역할을 합니다. left 속성의 경우에는 증기 효과가 수평방향으로 이동할지를 결정하여주는 것이고 bottom 속성의 경우에는 수직방향의 이동거리를 결정하는데 사용이 됩니다.

Sizedbox의 경우에는 이렇게 계산된 수치들을 가지고 정해진 크기를 가진 박스를 만들어 증기 효과를 원으로 그립니다.

CustomPaint 위젯은 특정 모양을 그리는데 주로 사용되는 위젯이며, 증기가 사라지는 효과는 1 - _animation.value로 구현을 하였다 (_animation은 객체 0 ~ 1 값을 나타내며 애니메이션의 현재 진행상태를 의미한다).

@override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  double get minimumSteamWidth =>
      (widget.screen.width - widget.sidePadding * 2) / OneSteam.maxSteamNumber;

  @override
  Widget build(BuildContext context) {
    var circleSize = (widget.screen.width - widget.sidePadding * 2) / 4;
    var circleWidth = (minimumSteamWidth + (circleSize - minimumSteamWidth)) *
        _animation.value;
    return Positioned(
      left: widget.sidePadding +
          (widget.index * minimumSteamWidth) -
          circleWidth / 2 +
          widget.sidePadding / 2,
      bottom: (widget.screen.height - circleSize) * _animation.value,
      child: SizedBox(
        width: circleWidth,
        height: circleSize,
        child: CustomPaint(
          painter: OneSteamPainter(1 - _animation.value),
        ),
      ),
    );
  }
}

 

3-3-5. CustomPainter의 구현과 Canvas 원의 생성 및 Blur 효과 처리

원은 canvas.drawOval 메소드를 사용하여 지정된 크기의 타원을 그렸고, Rect.fromLTWH를 사용해 꼭지점의 위치및 너비와 높이의 값을 받아 생성을 하였다. blur는 MaskFilter 클래스의 blur() 메소드를 사용하였으며 각각은 BlurStyle과 반경 값을 적용하는 것이다. p.color에서는 페인크 객체의 속성을 white로 지정하고 투명도를 조절하였다.

class OneSteamPainter extends CustomPainter {
  OneSteamPainter(this.opacity);
  final double opacity;

  @override
  void paint(Canvas canvas, Size size) {
    var p = Paint();
    p.color = Colors.white.withOpacity(opacity);
    p.maskFilter = const MaskFilter.blur(BlurStyle.normal, 50);

    canvas.drawOval(Rect.fromLTWH(0, 0, size.width, size.height), p);
  }

  @override
  bool shouldRepaint(covariant CustomPainter coldDelegate) {
    return true;
  }
}

 

이렇게 해서 만들어진 효과가 다음의 효과이다.

Steam image.gif

각각의 기능들에 대해서는 수치들을 변경해보고 나름대로 코드들을 더욱 분석해보면서, 공부해본다면 이를 응용한 효과를 만들 수 있을것이다.

 

그럼 또다시 흥미로운 주제로 찾아와보도록 하겠다.

728x90

'Flutter' 카테고리의 다른 글

[Package] flutter_animate  (0) 2023.04.23
[UI] Rain effect 만들기  (0) 2023.04.23
[Study] Supabase  (0) 2023.04.20
[코드분석] Minesweeper - 지뢰찾기 만들기  (0) 2023.04.20
5-5. Riverpod (E-commerce App)  (0) 2023.04.18