Flutter

[UI] Rain effect 만들기

모리선생 2023. 4. 23. 01:39
728x90

목표

Rain effect를 만들어 보고 Getx의 역할과 UI구성의 방식들을 이해한다.


이번에도 너무나도 귀여운 UI를 구성하는 코드가 있어서 코드를 따라 쳐보면서 나름대로 코드를 해석해보았다. 해당 코드는 도그풋님의 Youtube 중 [Flutter] ASMR - Rain animation을 참조하였다.

 

원본 (Github): https://github.com/wownsdl13/flutter-rain-example

변경 (Github): https://github.com/riris01/flutter_rain

 

그럼 시작!

 

1. 파일 구성

이번엔 파일이 좀 많다 하지만 천천히 적어보자

 

lib

ㄴ main.dart

ㄴscreens

    ㄴwindow_page

        ㄴ local_utils

            ㄴ window_controller.dart

        ㄴ local_widgets

            window_page.dart

            ㄴ local_utils

                ㄴ rain_controller.dart

            ㄴ local_widgets

                ㄴ one_drop.dart

            ㄴ rain.dart

이렇게 구성을 하였다.

 

1. dependencies

두가지를 사용한다.

dependencies:
  
  get: 
  uuid:

 

2. main.dart

개인적으로 MaterialApp과 Scaffold를 명확하게 구분해서 생성하는 것을 좋아하여 위젯을 두개를 만든다.

MaterialApp 앞에는 Get을 붙여둔다 (즉, GetMaterialApp).

import 'package:flutter/material.dart';
import 'package:flutter_rain/screens/window_page/local_utils/window_controller.dart';
import 'package:get/get.dart';

import 'screens/window_page/window_page.dart';
import 'dart:ui';

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

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

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      debugShowCheckedModeBanner: false,
      initialBinding: BindingsBuilder(() {
        Get.put(WindowController());
      }),
      home: MyPage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    Widget child = const WindowPage();
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              width: 400,
              height: 400,
              child: child,
            ),
          ],
        ),
      ),
    );
  }
}

 

3. window_page.dart

window_page.dart를 먼저 불러서 child로 선언을 하고 있으니 일단은 해당 파일의 내용부터 보자. 해당 코드에서는 GetxController() 메소드를 불러와서 get을 사용할 수 있도록 준비를 해두었으며, 이를 통해 Get.find를 사용할 수 있다. Get.find는 컨트롤러의 상태를 실시간으로 반영 혹은 변경을 하는데 사용이 된다. 그 외에는 거의 창틀이라던가 비가오는 화면의 background에서 야외의 색상을 변경을 추가해놓았다.

import 'package:flutter/material.dart';
import 'package:flutter_rain/screens/window_page/local_utils/window_controller.dart';
import 'package:get/get.dart';

import 'local_widgets/rain/rain.dart';

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

  @override
  Widget build(BuildContext context) {
    return GetBuilder<WindowController>(
      init: Get.find<WindowController>(),
      builder: (controller) {
        return window(controller);
      },
    );
  }

  Widget window(WindowController controller) {
    return Center(
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Transform(
            transform: Matrix4.identity()
              ..setEntry(3, 2, 0.01)
              ..rotateY(.08),
            alignment: Alignment.centerRight,
            child: AspectRatio(
              aspectRatio: 4 / 5,
              child: LayoutBuilder(
                builder: (context, constraint) {
                  var size = constraint.biggest;
                  return Container(
                    decoration: BoxDecoration(color: Colors.brown),
                    padding: EdgeInsets.all(size.width / 10),
                    child: Stack(
                      children: [
                        Container(
                          decoration: BoxDecoration(
                              gradient: LinearGradient(
                                  begin: Alignment.topLeft,
                                  end: Alignment.bottomRight,
                                  colors: [
                                Colors.black,
                                Colors.blue.shade900
                              ])),
                        ),
                        Rain(screen: size),
                      ],
                    ),
                  );
                },
              ),
            ),
          ),
          Container(
            width: 20,
            color: Colors.brown.shade700,
          )
        ],
      ),
    );
  }
}

 

4. screens/window_page/local_utils/window_controller.dart

아까전에도 보았드시, GetxController의 속성을 WindowController 클래스를 만들어 주었다.

import 'package:get/get.dart';

class WindowController extends GetxController {}

 

5. rain.dart

StatelessWidget으로 Rain 클래스를 만들어 주었다. 여기서는 OneDrop 이 비 하나가 떨어지는 물방울을 표현하였으며, RainController 클래스는 물방울이 어떻게 떨어지는지를 제어하는 역할을 한다. 두가지의 객체와 제어를 한 곳에서 할 수 있도록 해당 클래스가 생성이 되었다.

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_rain/screens/window_page/local_widgets/rain/local_utils/rain_controller.dart';
import 'package:flutter_rain/screens/window_page/local_widgets/rain/local_widgets/one_drop.dart';
import 'package:get/get.dart';
import 'dart:ui';

class Rain extends StatelessWidget {
  const Rain({Key? key, required this.screen}) : super(key: key);
  final Size screen;

  @override
  Widget build(BuildContext context) {
    return GetBuilder<RainController>(
        init: Get.put(RainController()),
        builder: (controller) {
          return Stack(
              children: controller.rainDropId
                  .map((e) => OneDrop(id: e, screen: screen))
                  .toList());
        });
  }
}

 

6. raincontroller.dart

List.generate 함수를 이용하여  리스트(30)을 생성을 하면 rainDropId라는 고유 식별자를 통해 각 물방울이 고유 ID를 가질 수 있도록 Uuid가 부여되게 된다. resetDrop의 경우 물방울이 내리고 나면 사라져야하는데, 그 후 새로운 물방울이 각 고유한 ID를 가질 수 있도록 한다.

import 'package:get/get_state_manager/src/simple/get_controllers.dart';
import 'package:uuid/uuid.dart';

class RainController extends GetxController {
  final rainDropId = List.generate(30, (index) => const Uuid().v1());

  void resetDrop(String id) {
    rainDropId.remove(id);
    rainDropId.add(const Uuid().v1());
    update();
  }
}

 

7. one_drop.dart

해당 코드에서는 One drop을 어떻게 구성을 하고 있는지를 볼 수가 있다. screen과 id를 각각 객체로 선언을 하였으며, SingleTickerProviderStateMixin을 사용하여 Animation 구성시에 필요한 속성들이 무엇인지 선언하고 있다.

 

initState()에서는 처음 물방울의 생성되는 위치와 움직임 그리고 투명도, 크기를 계산하였으며,  AnimuationStatus.completed 즉, 물방울이 떨어지고 난 후에는 resetDrop을 이용해 다시 물방울이 ID를 부여받아 떨어지는 애니메이션이 나타나도록 구현을 하였다.

 

dispose 클래스의 경우 사용된 AnimationController 객체가 함께 제거되도록 하여 메모리 누수를 방지하도록 해두었다. StateFulWidget이 제거될 때는 State 객체도 함께 제거 되지만 리소스 (예: AnimationController)는 자동으로 제거되지 않으므로 수동으로 제거해야한다. 그러므로 _controller.dispose()를 호출하여 _controller 객체를 제거한다.

 

마지막으로 build에서는 물방울 하나당 화면에서 차지하는 비율 dropHeight, dropWidth등을 설정하였다. 그리고 물방울 자체의 색도 설정을 해두어 살짝 투명해보이는 효과를 주었다. 만약 억수같이 쏟아지는 비를 표현하고 싶다면 dropHeight 부분을 조절해보면 된다.

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_rain/screens/window_page/local_widgets/rain/local_utils/rain_controller.dart';
import 'package:get/get.dart';

class OneDrop extends StatefulWidget {
  OneDrop({required this.id, required this.screen}) : super(key: ValueKey(id));
  final Size screen;
  final String id;

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

class _OneDropState extends State<OneDrop> with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation _animation;

  late double _startX, _moveX;
  late double _opacity;
  late double _scale;

  @override
  void initState() {
    _startX =
        widget.screen.width * Random().nextDouble() + widget.screen.width / 10;
    _moveX = 10 + 10 * Random().nextDouble();
    _opacity = .6 + .4 * Random().nextDouble();
    _scale = .8 + .2 * Random().nextDouble();
    _controller = AnimationController(
        vsync: this,
        duration: Duration(
            milliseconds:
                400 + (200 * Random().nextDouble()).floor())); // 0.4 ~ 0.6
    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
    _controller.addListener(() {
      setState(() {});
    });
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        // change drop pos
        var rainController = Get.find<RainController>();
        rainController.resetDrop(widget.id);
      }
    });
    _controller.forward();
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    var dropHeight = widget.screen.height * .17;
    var dropWidth = dropHeight * .08;
    return Positioned(
      top: (widget.screen.height + dropHeight) * _animation.value - dropHeight,
      right: _startX + _moveX * _animation.value,
      child: RotationTransition(
        turns: AlwaysStoppedAnimation(_moveX * .1 / 360),
        child: Transform.scale(
          scale: _scale,
          child: Container(
            height: dropHeight,
            width: dropWidth,
            decoration: BoxDecoration(
                color: Colors.blueGrey.withOpacity(_opacity),
                borderRadius: BorderRadius.circular(30)),
          ),
        ),
      ),
    );
  }
}

 

최종 결과물

비가오는 창.gif

 

728x90

'Flutter' 카테고리의 다른 글

[Android] Firebase - Flutter 연결하기  (0) 2023.04.25
[Package] flutter_animate  (0) 2023.04.23
[UI] 증기 (Steam) 만들기  (0) 2023.04.21
[Study] Supabase  (0) 2023.04.20
[코드분석] Minesweeper - 지뢰찾기 만들기  (0) 2023.04.20