Flutter | Example | BottomNavigationBar, TabBarView, Slider, sensors_plus, shake

플러터(Flutter)는 구글에서 만든 크로스 플랫폼 프레임워크다.

이번 글에서는 Plugin sensors_plus, shake를 이용하여 핸드폰을 흔들면 Random 숫자를 보여주는 주사위 APP를 만들어 봅니다.

sensors_plus 패키지(package)와 shake 패키지

sensors_plus 패키지는 핸드폰의 가속도계와 자이로스코프 센서를 편리하게 사용할 수 있게 해줍니다.

하지만 이 패키지만으로는 핸드폰을 흔들었다고 판단할 수 있는 수학적 로직을 알기가 어렵기에 핸드폰을 흔든 정도에 따라 처리를 쉽게 할 수 있게 해주는 shake 패키지를 함께 사용합니다. 아래와 같이 터미널에서 flutter pub add 명령으로 간편하게 추가 가능합니다.

// senseors_plus package 추가 명령
$ flutter pub add sensors_plus

// shake package 추가 명령
$ flutter pub add shake

Random Dice 프로젝트(Project) 파일 구조

random-dice-file-struct
Random Dice Project File Struct

상수(constant) 정의

‘const’폴더와 colors.dart 파일을 추가하여 프로젝트에서 사용할 상수 값들을 정의해 주었다.

colors.dart

import 'package:flutter/material.dart';

const backgroundColor = Color(0xFF0E0E0E);
const primaryColor = Colors.white;
final secondaryColor = Colors.grey[600];  // [600] = shade(음영 단계)

이미지 추가

asset과 img 폴더를 추가하고 주사위 이미지로 쓸 1~6.png를 추가해 줍니다.

pubspec.yaml

...
dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8
  sensors_plus: ^6.1.1
  shake: ^3.0.0
...
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg
  assets:
    - asset/img/

위와 같이 작성 후 터미널이나 안드로이드 스튜디오에서 pub get 명령을 실행하여 적용해 줍니다.

$ flutter pub get

화면(Screen) 소스 코드(Source Code)

main.dart

import 'package:flutter/material.dart';
import 'package:random_dice/const/colors.dart';
import 'package:random_dice/screen/root_screen.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'Random Dice',
      theme: ThemeData(
        scaffoldBackgroundColor: backgroundColor,
        sliderTheme: SliderThemeData(    // Slider 위젯 테마
          thumbColor: primaryColor,
          activeTrackColor: primaryColor,
          inactiveTrackColor: primaryColor.withValues(alpha: 0.3),
        ),
        // BottomNavigationBar 위젯 테마
        bottomNavigationBarTheme: BottomNavigationBarThemeData(
          selectedItemColor: primaryColor,            // 선택 색상
          unselectedItemColor: secondaryColor,  // 비선택 색상
          backgroundColor: backgroundColor,      // 배경 색상
        ),
      ),
      home: RootScreen(),
    )
  );
}

root_screen.dart

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:random_dice/screen/home_screen.dart';
import 'package:random_dice/screen/settings_screen.dart';
import 'package:shake/shake.dart';

class RootScreen extends StatefulWidget {
  const RootScreen({super.key});

  @override
  State<RootScreen> createState() => _RootScreenState();
}

// vsync 기능이 필요하기 때문에 TickerProviderMinin을 minin으로 제공해야해서 with TickerProviderStateMixin
class _RootScreenState extends State<RootScreen> with TickerProviderStateMixin {
  TabController? tabController;
  double threshold = 2.7;
  int number = 1;
  ShakeDetector? shakeDetector;

  @override
  void initState() {
    super.initState();
    tabController = TabController(length: 2, vsync: this);
    tabController!.addListener(tabListener);

    shakeDetector = ShakeDetector.autoStart(    // 핸드폰 흔들기 감지 센서 디텍터 시작
      shakeSlopTimeMS: 100,  // 감지 주기
      shakeThresholdGravity: threshold,    // 핸드폰 흔들기 감지 민감도 값 매개변수
      onPhoneShake: onPhoneShake,        // 핸드폰 흔들기 감지시 콜백 함수 매개변수
    );
  }

  void onPhoneShake(event) {
    final rand = Random();

    setState(() {
      number = rand.nextInt(5) + 1;
    });
  }

  tabListener() {
    setState(() {});
  }

  @override
  void dispose() {
    tabController!.removeListener(tabListener);
    shakeDetector!.stopListening();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: TabBarView(controller: tabController, children: renderChildren()),  // 탭 화면
      bottomNavigationBar: renderBottomNavigation(),    // 하단 탭 선택에 따른 렌더링 및 화면처리 구현 매개변수
    );
  }

  List<Widget> renderChildren() {
    return [
      // Center(
      //   child: Text('Tab 1', style: TextStyle(color: Colors.white)),
      // ),
      HomeScreen(number: number),    // 주사위가 있는 홈 스크린 페이지
      // Center(
      //   child: Text('Tab 2', style: TextStyle(color: Colors.white)),
      // ),
      SettingsScreen(    // 핸드폰 흔들기 민감도 설정 스크린 페이지
        threshold: threshold,
        onThresholdChange: onThresholdChange,
      ),
    ];
  }

  void onThresholdChange(double val) {
    setState(() {
      threshold = val;
    });
  }

  BottomNavigationBar renderBottomNavigation() {
    // 탭 내비게이션 구현
    return BottomNavigationBar(
      currentIndex: tabController!.index,
      onTap: (int index) {
        setState(() {
          tabController!.animateTo(index);
        });
      },
      items: [
        BottomNavigationBarItem(
          icon: Icon(Icons.edgesensor_high_outlined),
          label: 'Dice',
        ),
        BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Setting'),
      ],
    );
  }
}

위 코드에서 vsync 기능을 사용하기 위해 TickerProviderStateMinin을 사용했습니다.

TickerProviderMinin과 SingleTickerProviderMinin은 애니메이셔녀 효율을 올려주는 역할을 합니다.

플러터(Flutter)는 기기가 지원하는 대로 60FPS(초당 60프레임)부터 120FPS를 지원하는데 TickerPrividerMinin을 사용하면 정확히 한 틱(1FPS)마다 애니메이션을 실행합니다. 간혹 애니메이션 코드를 작성하다 보면 실제로 화면에 그릴 수있는 주기보다 더 자주 렌더링을 실행하게 될 때가 있는데 TickerProviderMixin을 사용하면 이런 비효율적인 상황을 막아줍니다. TabController도 vsync에 TickerProviderMixin을 제공함으로서 렌더링 효율을 극대화할 수 있습니다.

home_screen.dart

import 'package:flutter/material.dart';
import 'package:random_dice/const/colors.dart';

class HomeScreen extends StatelessWidget {
  final int number;

  const HomeScreen({required this.number, Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Center(child: Image.asset('asset/img/$number.png')),
        SizedBox(height: 32.0),
        Text(
          'Lucky Number',
          style: TextStyle(
            color: secondaryColor,
            fontSize: 20.0,
            fontWeight: FontWeight.w700,
          ),
        ),
        SizedBox(height: 12.0),
        Text(
          number.toString(),
          style: TextStyle(
            color: primaryColor,
            fontSize: 60.0,
            fontWeight: FontWeight.w200,
          ),
        ),
      ],
    );
  }
}

settings_screen.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:random_dice/const/colors.dart';

class SettingsScreen extends StatelessWidget {
  final double threshold;

  // Slider 변경 시에 호출되는 함수
  final ValueChanged<double> onThresholdChange;

  const SettingsScreen({
    super.key,
    required this.threshold,
    required this.onThresholdChange,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Padding(
          padding: const EdgeInsets.only(left: 20.0),
          child: Row(
            children: [
              Text(
                'Sensitive',
                style: TextStyle(
                  color: secondaryColor,
                  fontSize: 20.0,
                  fontWeight: FontWeight.w700,
                ),
              ),
            ],
          ),
        ),
        Slider(
          min: 0.1,
          max: 10.0,
          divisions: 101,  // 최소값(min)과 최대값(max) 사이의 구간의 개수
          value: threshold,  // 슬라이더 값
          onChanged: onThresholdChange,  // 값 변경 시 호출 함수
          label: threshold.toStringAsFixed(1),  // 슬라이더 라벨 표시값 (소수점 1자리까지 표시 설정)
        ),
      ],
    );
  }
}

Summary

이번 예제는 자이로 센서가 장착되어 있는 실제 핸드폰에서 테스트가 가능합니다.

캐쉬워크와 같은 APP 정도는 이제 플러터(Flutter)로 금방 만들어 낼 수 있을 것 같습니다. 😁


참고

Flutter | Windows 개발 환경 구축

Flutter 기본 기능 | as, show, hide | 변수나 함수, 클래스명이 같은 경우 해결 방법 | example

댓글 남기기