DAY 13 | Flutter 데모 앱 만들기: 상세 페이지에 기능 추가하기

Pt J·2026년 7월 29일
post-thumbnail

DAY 13 | Flutter 데모 앱 만들기: 상세 페이지에 기능 추가하기

Stateless로 작성한 상세 페이지 화면에 onPressed 제스처에 대한 콜백함수를 작성하고 state를 사용하는 방식으로 기능을 추가해 보자.

모바일에서 사용되는 주요 제스처

gesturedescription
tap손가락 하나로 건드린다 (툭 치는 느낌)
double tap손가락 하나로 연달아 건드린다
drag손가락 하나로 누른 채 다른 방향으로 움직인다
flick손가락 하나로 누르다 다른 방향으로 튕긴다
pinch손가락 두 개로 누른 채 오므린다
spread손가락 두 개로 누른 채 벌린다
press손가락 하나로 누른다 (지긋이 누르는 느낌)
press and tap손가락 하나로 press 한 채 다른 손가락으로 tap 한다
press and drag손가락 하나로 press 한 채 다른 손가락으로 drag 한다
rotate두 손가락으로 누른 채 상대적 위치를 회전한다

그 외에도 여러 제스처가 있을 수 있다.

GestureDector 공식 문서에서 Flutter에서 사용 가능한 제스처를 확인할 수 있다. 커스텀 제스처도 정의할 수는 있지만 UX 측면에서 얼마나 좋을지는, 글쎄.

버튼은 이 중에서 press에 대한 onPressed property에 callback function을 전달해야 한다.

state를 포함하는 widget으로 변환하기

상세 페이지의 재생 버튼이 작동하도록 기능을 추가해 보자. assets/audio 디렉토리를 생성하여 MP3 파일을 넣어놓고 버튼을 누르면 소리가 재생되도록 할 것이다.

먼저 Stateless Widget으로 작성되어 있는 WorkoutGuidePage widget을 Stateful Widget으로 변경한다. 이는 class 이름에 마우스를 올리고 Context Actions에서 [Convert to StatefulWidge]을 눌러 쉽게 할 수 있다.

lib/workout_guide_page.dart

import 'package:flutter/material.dart';

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

  
  State<WorkoutGuidePage> createState() => _WorkoutGuidePageState();
}

class _WorkoutGuidePageState extends State<WorkoutGuidePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      // 생략
    );
  }
}

Stateful Widget

  • StatefulWidgetextends 하는 widget class와 State<StatefulWidget>extends 하는 state class로 구성된다.
  • state는 widget에 binding되며 lifecycle이 다르다.
  • state가 더 오래 살아 있고, 데이터 변경에 따라 widget이 갱신된다.
  • 데이터는 widget이 아닌 state 쪽에 두고, widget은 데이터에 변화에 따라 state에 의해 다시 build된다.
  • 데이터가 변경되었을 때 build를 다시 하여 화면을 다시 그려주는 lifecycle function은 setState() 다.

MP3 재생을 위한 package를 pub.dev에서 탐색한다. 대중적인 건 just_audioaudioplayers가 있다. just_audio 는 응용 능력이 많고 audioplayers 는 좀 더 순수한 기능을 담고 있다. 추구하는 바에 따라 선택하면 되는데 우리는 audioplayers 를 사용한다.

package 가져오기

pubspec.yamldependencies: 부분에 패키지 정보를 추가해야 사용할 수 있다.

pubspec.yaml

# 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
# 뒷 부분 생략

assets/audio 디렉토리에 MP3 파일을 담고 이것도 등록해 준다.

pubspec.yaml

# 앞 부분 생략
flutter:

  uses-material-design: true

  assets:
    - assets/
    - assets/audio/
# 뒷 부분 생략

로직 작성하기

공식문서의 README.md 문서를 따라 패키지를 사용하는 코드를 작성한다. 버튼을 누르면 정지 버튼으로 바뀌며 소리가 나기 시작하고 다시 누르면 재생 버튼으로 바뀌며 재생이 멈추는 코드로 작성할 것이므로, 기존에 작성한 IconButton widget에서 [refactor > Extract Method]을 수행한다.

lib/workout_guide_page.dart

// 앞 부분 생략

class _WorkoutGuidePageState extends State<WorkoutGuidePage> {
  final player = AudioPlayer();

  IconButton buildPlayButton() {
    return player.state == PlayerState.playing
        ? IconButton(
            onPressed: () async {
              await player.stop();
              setState(() {});
            },
            icon: Icon(Icons.stop_circle, color: Colors.blue),
            iconSize: 70,
          )
        : IconButton(
            onPressed: () async {
              await player.play(AssetSource('audio/squat.mp3'));
              setState(() {});
            },
            icon: Icon(Icons.play_circle, color: Colors.blue),
            iconSize: 70,
          );

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Workout Guide'), centerTitle: true),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Row(
      		// 생략: 제목이 들어가는 곳
          ),
          AspectRatio(
      		// 생략: 사진이 들어가는 곳
          ),
          Padding(
      		// 생략: 부연 설명이 들어가는 곳
          ),
          Padding(
      		// 생략: 소요 시간이 들어가는 곳
          ),
          buildPlayButton(),
        ],
      ),
    );
  }
}

onPressed property에 전달하는 callback function은 AudioPlayer 를 사용하는 method가 concurrency 하기 때문에 async 를 붙여 주어야 한다.

setState(() {}); 를 해주지 않으면 화면이 갱신되지 않는데, 이것이 "state가 변경되었으니 build를 다시하여 widget을 갱신하라" 하고 말해주는 녀석이기 때문이다. 보통은 setState(() {}); 의 callback function에 state가 변경되는 코드를 집어넣어 무엇이 state를 바꾸는지 명시해 주는 경우가 많다. 우리 코드에서는 state가 바뀌는 부분에 concurrency가 적용되어 있어 await 후에 setState(() {}); 한다.

setState(() async {}); 하면 안 되나?

onPressed: () async {
  await player.play(AssetSource('audio/squat.mp3'));
  setState(() {});
}

와 같이 작성하는 대신

onPressed: () {
  setState(() async {
    await player.play(AssetSource('audio/squat.mp3'));
  });
}

와 같이 작성하면 안 되는지 의문이 들 수 있다. 하지만 이렇게 사용할 수 없다.
setState() 에 concurrent function을 전달하면 lifecycle이 꼬여 원하는 대로 화면이 그려지지 않을 수 있다.

[소리 재생 시작 → setState 호출 → 화면 갱신]의 흐름으로 진행되어야 하는데, 순서가 꼬이면 [setState 호출 → 화면 갱신 → 소리 재생 시작]이 되어 소리가 재생되기 시작해도 화면이 갱신되지 않을 수 있다.

결론적으로 await 가 필요한 작업은 setState() 외부에서 처리해야 한다.

앱의 전체 디자인 설정 파일, Theme

디자인 명세를 Design System으로 정의해 놓고 하나의 파일로 관리하면 앱 전반적인 디자인 설정을 쉽게 변경할 수 있다. 강조색을 하드코딩했을 경우에는 모두 찾아서 바꾸어야 하지만 accentColor 로 지정해 놓았다면 이것만 바꾸면 된다거나. 텍스트 크기도 미리 설정해 놓고 사용하는 게 일관성 있게 구현하는 데 도움이 된다.

색상 및 폰트 크기 등을 앱 내에서 하드코딩하기보다는 theme를 지정해 놓고 그 안에 있는 값들을 사용하는 편이 유지보수하기 좋다. 가령 다크모드를 만들 경우 전체 테마만 다크모드로 바꾸면 세부적인 코드 구현을 수정하지 않고 한 번에 다크 모드로 변경할 수 있다.

Flutter에서는 MaterialApptheme property를 사용하여 디자인 명세를 정의할 수 있다. ThemeData widget을 사용해도 되고 이를 기반으로 한 custom widget이나 외부 package를 사용해도 되는데, 우리는 flex_color_scheme package를 사용하여 theme를 지정할 것 이다. Themes Playground에서 원하는 색 조합을 탐색할 수 있다.

이 또한 pubspec.yamldependencies: 부분에 패키지 정보를 추가해야 사용할 수 있다.

pubspec.yaml

# 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
  flex_color_scheme: ^8.4.0
# 뒷 부분 생략

나는 Blue Whale이라는 이름의 theme를 적용해 보았다.

lib/main.dart

import 'package:flutter/material.dart';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'landing_page.dart';
import 'workout_list_page.dart';
import 'workout_guide_page.dart';

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

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      //home: const LandingPage(),
      // home: WorkoutListPage(),
      home: WorkoutGuidePage(),
      theme: FlexThemeData.light(
        scheme: FlexScheme.blueWhale,
        fontFamily: 'Pretendard',
        subThemesData: const FlexSubThemesData(
          appBarBackgroundSchemeColor: SchemeColor.primary,
        )
      ),
    );
  }
}

하드코딩 되어 있는 색상 및 폰트 크기를 theme에 정의된 값으로 변경하였다.

lib/workout_guide_page.dart

  • 재생 버튼 색상: Colors.blueTheme.of(context).colorScheme.secondary
  • 제목 색상: default (black) → Theme.of(context).colorScheme.primary
  • 제목 크기: 30Theme.of(context).textTheme.headlineMedium?.fontSize
  • 설명 색상: Colors.greyTheme.of(context).colorScheme.primaryFixedDim
  • 설명 크기: 12Theme.of(context).textTheme.bodySmall?.fontSize
  • (...나머지도 마찬가지로 변경)

매번 Theme.of(context).colorScheme 로 접근하여 색을 가져오고 Theme.of(context).textTheme 로 접근하여 텍스트 정보를 가져오고 하면 탐색 과정이 비효율적일 수 있다. 이 때, 상단에서 미리 ColorScheme colorScheme = Theme.of(context).colorScheme; 와 같이 가져와 놓으면 탐색 과정을 줄일 수 있다.

기능 추가하기

MP3 파일이 반복 재생되다가 운동 시간이 끝나면 자동으로 종료되도록 코드를 작성해 보자. 테스트를 위해 운동 시간을 30분이 아니라 3분으로 변경해 보겠다.

시간이 흐르는 건 Timer를 사용하여 구현한다. Timer.periodic라는 named constructor를 통해 정해진 시간마다 callback function을 수행하는 타이머를 생성할 수 있다.

재생 버튼을 누르면 소리가 나오기 시작하며 시간이 흐르기 시작하고, 버튼을 다시 누르면 (정지가 아닌!) 일시정지가 되며 시간이 멈추도록 작성하였다. 이에 따라 아이콘도 Icons.pause_circle 로 변경하였다.

_player.setReleaseMode(ReleaseMode.loop); 를 통해 파일의 끝에 도달했을 때 처음부터 다시 실행되게 할 수 있다. 그런데 끝에 도달하고 다시 실행하기 전에 gap이 있어 버튼이 일시정지로 바뀌었다가 잠시 후 재생으로 바뀌더라. 이 gap을 없앨 수 있나 찾아보니 package가 native MediaPlayer 설정을 그대로 가져다 사용하는데 거기서 발생한 이슈라 어떻게 못 하는 모양이다. 음성이 끝날 때쯤 새 음성을 맞물리는 방식으로 어떻게 할 수 있다는데 그것까지는 여기선 너무 복잡하니까.

그래서 buildPlayButton() 에서 _player.state == PlayerState.playing 에 따라 버튼을 바꾸는 게 아니라 사전 정의된 _isPlaying 에 따라 바꾸는 것으로 수정했다.

수업에서는 void initState()void dispose() 에서 플레이어의 lifecycle을 관리하는 건 다루지 않았지만 아무래도 있는 게 좋을 테니 그것도 추가해놨다.

lib/workout_guide_page.dart

// 앞 부분 생략
class _WorkoutGuidePageState extends State<WorkoutGuidePage> {
  final _player = AudioPlayer();
  int _remainTime = 180;
  Timer? _timer;
  bool _isPlaying = false;

  
  void initState() {
    super.initState();
  _player.setReleaseMode(ReleaseMode.loop);
  }

  
  void dispose() {
    _player.dispose();
    _timer?.cancel();
    super.dispose();
  }

  IconButton buildPlayButton() {
    // _isPlaying = _player.state == PlayerState.playing;
    return _isPlaying
        ? IconButton(
            onPressed: () async {
              await _player.pause();
              setState(() {
                _isPlaying = false;
              });
              _timer?.cancel();
            },
            icon: Icon(
              Icons.pause_circle,
              color: Theme.of(context).colorScheme.secondary,
            ),
            iconSize: 70,
          )
        : IconButton(
            onPressed: () async {
              await _player.play(AssetSource('audio/squat.mp3'));
              setState(() {
                _isPlaying = true;
              });
              _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
                setState(() {
                  _remainTime--;
                });
                if (_remainTime <= 0) {
                  timer.cancel();
                  _remainTime = 180;
                  _player.stop();
                  setState(() {
                    _isPlaying = false;
                  });
                }
              });
            },
            icon: Icon(
              Icons.play_circle,
              color: Theme.of(context).colorScheme.secondary,
            ),
            iconSize: 70,
          );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Workout Guide'), centerTitle: true),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Row(
      		// 생략: 제목이 들어가는 곳
          ),
          AspectRatio(
      		// 생략: 사진이 들어가는 곳
          ),
          Padding(
      		// 생략: 부연 설명이 들어가는 곳
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 30),
            child: Row(
              children: [
                Expanded(
                  child: Container(
                    padding: EdgeInsets.all(16),
                    decoration: BoxDecoration(
                      border: Border.all(
                        color: Theme.of(context).colorScheme.outlineVariant,
                      ),
                      borderRadius: BorderRadius.all(Radius.circular(16)),
                    ),
                    child: Text(
                      '${_remainTime ~/ 60}분 ${_remainTime % 60}초',
                      style: TextStyle(
                        fontSize: Theme.of(
                          context,
                        ).textTheme.titleLarge?.fontSize,
                        fontWeight: FontWeight.bold,
                        color: Theme.of(context).colorScheme.secondary,
                      ),
                      textAlign: TextAlign.center,
                    ),
                  ),
                ),
              ],
            ),
          ),
          buildPlayButton(),
    };
  }
}

재생 버튼을 누르면 소리가 재생되며 [3분 0초]였던 게 [2분 59초]부터 해서 줄어들기 시작하고, 일시정지 버튼을 누르면 소리와 타이머가 멈추며, 다시 재생 버튼을 누르면 소리와 타이머가 멈췄던 곳부터 다시 시작한다. 끝에 도달하여 [0분 0초]가 되면 타이머가 멈추고 소리도 정지하며, 남은 시간이 초기화된다.


>>> GitHub Repository at this point (f420618)

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글