본문 바로가기
추천시스템 앱 개발/프론트엔드(Flutter)

[Flutter/플러터] 영화추천 화면 만들기 - 영화 선택 페이지 3

by 직_장인 2025. 3. 6.

3. 영화 선택 페이지

3-2. 영화 선택 페이지 - 기능 별 코드 설명

3-2-8. progressBar로 영화 선택 바 구현하기

Widget progressBar(BuildContext context, List<String> selectedMovies) {
  return Padding(
    padding: const EdgeInsets.all(15),
    child: Stack(
      alignment: Alignment.center,
      children: [
        LinearProgressIndicator(
          value: selectedMovies.length / 10,
          backgroundColor: Colors.grey[300],
          color: mainColor,
          minHeight: 10,
        ),
        Positioned.fill(
          child: CustomPaint(
            painter: GridPainter(),
          ),
        ),
      ],
    ),
  );
}
  • 선택한 영화의 개수에 따라 진행 상황을 시각적으로 보여주는 ProgressBar를 위젯으로 구현했다.
  • Stack: 위젯 겹치기.
    • 앞에서 한번 써봤다. 여러 위젯을 겹처서 표시하는 위젯이다.
    • LinearProgressIndicator, Positioned.fill 위젯을 stack 으로 쌓아서 선택된 영화 갯수만큼 bar가 채워지도록 만들었다.
  • LinearProgressIndicator(): 진행 bar를 표시하는 futter 기본 위젯.
    • 사용자가 선택한 영화의 개수는 selectedMovies.length로 측정되며, selectedMovies.length / 10의 계산을 통해 진행률로 계산되도록 했다.
    • 따라서 최대 10개의 영화를 기준으로 진행 바가 표시되고, 사용자가 10개의 영화를 모두 선택하면 progressBar가 완전히 채워진다.
    • backgroundColor는 진행되지 않은 부분을 표시하는 회색(Colors.grey[300])으로 설정되었으며, selectedMovies 갯수만큼 영화 선택이 진행된 부분인 color는 mainColor로 바뀐다.
  • Positioned.fill(): 특정 위젯으로 채우기.
    • Stack 내에서 자식 위젯이 부모의 전체 크기를 채우게 만드는 역할을 한다.
    • 여기서는 격자를 그려줄 CustomPaint 위젯이 진행 바 전체에 걸쳐 표시된다.

이렇게 영화 선택 과정에서 사용자가 현재 얼마나 많은 영화를 선택했는지를 실시간으로 보여주고. 사용자가 영화를 하나씩 선택할 때마다 진행 바가 점차적으로 채워지도록 만들었다.

 

 

3-2-9. GridPainter 클래스로 수직선과 숫자 표시

  • GridPainter 클래스를 이용하여 CustomPainter를 상속받아 Canvas에 직접 그리드와 텍스트를 그리는 기능을 구현했다.
  • Flutter에서 CustomPainter는 사용자 정의 그래픽을 그릴 수 있도록 제공되는 기능이다. 일반적인 위젯들로 표현하기 어려운 도형라인텍스트 등을 Canvas 위에 직접 그릴 수 있다.
  • 여기서는 수직선과 각 선에 해당하는 숫자 텍스트를 표시한다.
class GridPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.black
      ..strokeWidth = 2;
    final textPainter = TextPainter(
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );

    for (int i = 0; i < 10; i++) {
      double x = size.width * (i + 1) / 10;
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);

      textPainter.text = TextSpan(
        text: '${i + 1}',
        style: const TextStyle(color: Colors.black, fontSize: 12),
      );
      textPainter.layout();
      textPainter.paint(canvas, Offset(x - size.width / 20 - textPainter.width / 2, size.height / 2 - textPainter.height / 2));
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}
  • Canvas: 실제 그림을 그리는 도화지.
    • paint 메서드의 인수로 전달되며, 이곳에 직접 선을 긋거나 도형, 텍스트 등을 그릴 수 있다.
  • paint 메서드: Canvas에 그리기 작업을 수행하는 핵심 부분.
    • Paint(): 선을 그릴 때 사용할 색상두께를 설정.
      • color는 검정색(Colors.black)으로 설정한다.
      • strokeWidth는 선의 두께로, 적당한 굵기인 2로 설정한다.
    • TextPainter(): Canvas에 텍스트를 그리는 역할.
      • textAlign은 텍스트의 정렬을 가운데로 설정한다.
      • textDirection은 텍스트의 방향을 좌에서 우(ltr)로 설정하여, 기본적인 텍스트 출력 방향을 지정한다.
    • for문을 통한 반복적인 그리기 작업.
      • for (int i = 0; i < 10; i++) 반복문은 10번 반복되며, 화면에 수직선숫자를 그린다.
      • double x = size.width * (i + 1) / 10;를 이용해 각 구간의 x 좌표를 계산한다. 이렇게 하면 진행 바의 전체 너비를 10등분하여 각 선의 위치를 정할 수 있다.
      • 수직선 그리기: canvas.drawLine()을 통해 각 수직선의 시작 좌표와 끝 좌표를 지정하여 선을 그린다. 각 수직선은 화면 너비를 10등분한 위치에 그려진다.
      • 숫자 텍스트 그리기: 각 수직선 아래에 1~10까지의 숫자를 표시한다. TextSpan을 사용하여 텍스트 스타일을 정의한 후, textPainter.layout()을 호출해 텍스트 크기를 계산하고, 적절한 위치에 배치하여 textPainter.paint()로 그린다.
    • 텍스트 위치 조정: 텍스트의 위치는 각 선의 중앙에 오도록 Offset을 사용해 조정되었다. 이를 통해 텍스트가 선에 정확히 맞게 배치될 수 있도록 했다.
  • shouldRepaint 메서드: Canvas를 다시 그릴 필요가 있는지 여부를 결정.
    • 여기서는 false로 설정하여, 이미 그려진 내용은 재사용하고, 다시 그릴 필요가 없음을 명시했다.

 

3-3. 영화 선택 페이지 - 전체 코드

 

import 'package:flutter/material.dart';
import 'package:movie_rec/pages/select_score_page.dart';

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

  @override
  State<SelectMoviePage> createState() => _SelectMoviePageState();
}

Color mainColor = Colors.amber;

class _SelectMoviePageState extends State<SelectMoviePage> {
  List<String> movies = List.generate(12, (index) => '영화 $index');
  List<String> selectedMovies = [];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: appBar(),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Padding(
            padding: EdgeInsets.all(15),
            child: Text(
              '재미있게 본 영화를 선택해주세요.(10개)',
            ),
          ),
          progressBar(context, selectedMovies),
          selectMovieTile(),
        ],
      ),
      bottomNavigationBar: bottomAppBarButton(context),
    );
  }

  AppBar appBar() {
    return AppBar(
      backgroundColor: mainColor,
      title: const Text('영화 추천 앱'),
    );
  }

  void toggleSelection(String movie) {
    setState(() {
      print('movie: ' + movie.toString());
      if (selectedMovies.contains(movie)) {
        // 만약 타일이 선택되어 있는 상태라면
        selectedMovies.remove(movie); // selectedMovies 리스트에서 제거
      } else {
        //만약 타일이 선택되어 있지 않은 상태라면
        if (selectedMovies.length < 10) {
          //그리고 선택된 영화가 아직 10개가 안되었다면
          selectedMovies.add(movie); // selectedMovies 리스트에 포함
        }
        // else { // 10개가 다 찼다면
        // (별다른 Action을 정의하지 않았기 때문에) selectedMovies 리스트에는 변화가 없다
        // }
      }
      print('selectedMovies: ' + selectedMovies.toString());
    });
  }

  Widget bottomAppBarButton(BuildContext context) {
    return BottomAppBar(
      child: ElevatedButton(
        onPressed: selectedMovies.length == 10
            ? () {
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) => SelectScorePage(selectedMovies: selectedMovies),
                  ),
                );
              }
            : null,
        style: ElevatedButton.styleFrom(
          textStyle: const TextStyle(fontSize: 20),
        ),
        child: selectedMovies.length == 10
            ? Text(
                '다음',
                style: TextStyle(color: mainColor),
              )
            : const Text('다음'),
      ),
    );
  }

  Widget selectMovieTile() {
    // GridView는 스크롤이 가능하기 때문에 그 상위에서 스크롤 범위를 정해줘야 함
    // 스크롤 가능 범위를 최대한 확장시켜서 사용하기 위해 Expanded를 사용
    return Expanded(
      child: GridView.builder(
        padding: EdgeInsets.zero,
        itemCount: movies.length, // 영화 갯수만큼 표시(현재는 List로 만든 movies변수 길이인 12개)
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3, // 가로 타일 갯수
          crossAxisSpacing: 1, // 가로 타일 간 간격
          mainAxisSpacing: 1, // 세로 타일 간 간격
          childAspectRatio: 1 / 1.5, // 타일의 가로세로 비율
        ),
        itemBuilder: (context, index) {
          // movies 수 만큼 index로 하나씩 출력(0~11)
          String movie = movies[index]; // movies 하나를 movie 변수에 할당
          // selectedMovies변수는 선택된 영화를 담기위에 앞서 정의한 변수로,
          // 현재의 movie가 selectedMovies에 포함되어 있으면 선택된 것이기 때문에 isSelected = True,
          // 포함되어 있지 않으면 선택되지 않은 것이기 때문에 isSelected = False가 됨
          bool isSelected = selectedMovies.contains(movie);

          // 타일 하나를 어떻게 표시할건지 정의
          return Padding(
            padding: const EdgeInsets.all(3.0),
            child: InkWell(
              onTap: () => toggleSelection(movie),
              child: Stack(
                children: [
                  Container(
                    // 위 코드와 동일한 부분
                    width: double.infinity,
                    height: 300,
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(10),
                      color: isSelected ? mainColor.withOpacity(0.7) : Colors.grey,
                    ),
                  ),
                  if (isSelected)
                    const Center(
                      child: Icon(
                        Icons.check,
                        color: Colors.white,
                        size: 50,
                      ),
                    ),
                  Container(
                    width: double.infinity,
                    height: 300,
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(10),
                      gradient: const LinearGradient(
                        colors: [Colors.transparent, Colors.black87],
                        begin: Alignment.topCenter,
                        end: Alignment.bottomCenter,
                      ),
                    ),
                  ),
                  Align(
                    alignment: Alignment.bottomCenter,
                    child: Padding(
                      padding: const EdgeInsets.all(10.0),
                      child: Text(
                        movie,
                        style: const TextStyle(
                          color: Colors.white,
                          fontSize: 12,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

Widget progressBar(BuildContext context, List<String> selectedMovies) {
  return Padding(
    padding: const EdgeInsets.all(15),
    child: Stack(
      alignment: Alignment.center,
      children: [
        LinearProgressIndicator(
          value: selectedMovies.length / 10,
          backgroundColor: Colors.grey[300],
          color: mainColor,
          minHeight: 10,
        ),
        Positioned.fill(
          child: CustomPaint(
            painter: GridPainter(),
          ),
        ),
      ],
    ),
  );
}

class GridPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.black
      ..strokeWidth = 2;
    final textPainter = TextPainter(
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );

    for (int i = 0; i < 10; i++) {
      double x = size.width * (i + 1) / 10;
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);

      textPainter.text = TextSpan(
        text: '${i + 1}',
        style: const TextStyle(color: Colors.black, fontSize: 12),
      );
      textPainter.layout();
      textPainter.paint(canvas, Offset(x - size.width / 20 - textPainter.width / 2, size.height / 2 - textPainter.height / 2));
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}

  • 이렇게 영화 선택 페이지에 대해 알아보았다.
  • 다음 글에서는 영화 점수 페이지에 대해 알아보자.

댓글