DAY 12 | Flutter 데모 앱 만들기: 상세 페이지 만들기

Pt J·어제
post-thumbnail

DAY 12 | Flutter 데모 앱 만들기: 목록 페이지 만들기 (2)

Expanded widget에 대한 복습 설명을 듣다가 문득 Expanded widget과 SizedBox wigdet에 대한 생각을 해 보았다. 다양한 목적으로 사용하지만 Expanded widget의 usecase 중 하나는 shrink하는 widget의 child가 shrink되지 않도록 감싸는 것이다. 또, child에게 크기 제약을 주는 역할도 할 수 있다.

그 역할은 SizedBox 도 할 수 있었던 것 같은데, 하고 생각해 보니 expand하여 화면을 채울 수 있는 만큼 최대한 채우느냐, 정해진 크기로 고정하여 채우느냐의 차이인 것 같다.

비교Expanded widgetSizedBox widget
크기남은 공간 채우기픽셀 단위로 고정
특징flex 속성으로 공간 나누어 갖기child 없이 여백용으로 사용 가능
목적화면 크기가 달라져도 크기 유연하게 변해야 할 때버튼이나 박스의 크기를 고정하거나 일정한 간경을 줄 때

코드 중복은 리팩토링을 통해 최소화하는 게 좋다는 이야기를 했다. 두 번 중복까지는 상황에 따라 그대로 두는 게 더 합리적일 때도 있지만 세 번 이상은 리팩토링하라고. 지난 시간에 일련의 Row wigdet들을 method로 빼낸 것처럼 말이다.

먼저 새로운 파일WorkoutGuidePage.dart 를 생성하고 임시로 main.dart 에서 이 화면을 띄우도록 한다.

lib/main.dart

import 'package:flutter/material.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: const WorkoutListPage(),
      home: WorkoutGuidePage(),
      theme: ThemeData(
        fontFamily: 'Pretendard',
      ),
    );
  }
}

part 01: 제목이 들어가는 곳

먼저 제목을 부분을 작성한다. 좌측에 운동 이름이 들어가고 우측에 운동 설명이 들어가는 구조다. 학습 자료에는 스쿼트에 마운틴 클라이머 설명이 적혀 있길래 AI한테 설명을 작성해 달라고 하여 넣었다.

lib/workout_guide_page.dart

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Workout Guide'), centerTitle: true),
      body: Column(
        children: [
          Row(
            spacing: 20,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                '스쿼트',
                style: TextStyle(
                  fontSize: 30,
                  fontWeight: FontWeight.bold,
                  decoration: TextDecoration.underline,
                  decorationColor: Colors.blue,
                  decorationThickness: 2.0,
                ),
              ),
              Text(
                '하체 근력을 강화하고\n탄력 있는 엉덩이 라인을 만드는\n대표적인 전신 운동입니다.',
                style: TextStyle(fontSize: 12, color: Colors.grey),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

수업에서 설명한 거랑은 조금 다르게 작성해 보았다. 같은 화면도 구현하는 방식은 개인차가 있으니까. '난 이게 더 좋은 것 같은데' 싶은 부분은 내 방식으로 작성한다.

수업과 다른 부분

  • spacing: 20
    • 수업에서는 SizedBox widget을 통해 여백을 주는 방식을 사용했다. child 수가 셋 이상일 때 여백 크기를 개별적으로 지정할 수 있다.
    • spacing 은 Flutter 최신 버전(3.27+)에서 추가된 property라서 오뢔된 버전에서는 사용할 수 없다.
    • child 수가 셋 이상일 때는 각 여백을 개별 설정할 수는 없지만 코드 한 줄로 모든 child 사이에 여백을 줄 수 있다.
  • mainAxisAlignment: MainAxisAlignment.center
    • 수업에서는 양 옆에 Spacer widget을 배치했다. 화면 크기가 달라질 때 불필요한 레이아웃 연산 비용이 들 수 있다.

part 02: 사진이 들어가는 곳

중앙에 사진이 있고 좌우에 화면을 넘기는 버튼이 있는 구조다. 화면을 넘기는 버튼은 IconButton widget을 사용한다. Icon 문서를 참고하자. Google Fonts에서 Material Icons를 확인할 수 있다.

lib/workout_guide_page.dart

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Workout Guide'), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            children: [
              IconButton(
                onPressed: () {},
                icon: Icon(Icons.arrow_back_ios_new),
                iconSize: 70,
              ),
              Expanded(child: Image.asset('assets/squat.png')),
              IconButton(
                onPressed: () {},
                icon: Icon(Icons.arrow_forward_ios),
                iconSize: 70,
              ),
            ],
          ),
        ],
      ),
    );
  }
}

asset 이미지를 사용할 땐 Image(image: AssetImage('IMAGE_URL')) 해도 되지만 Image.asset('IMAGE_URL') 해도 된다. 완전히 같은 것이고 constructor 차이다. generative constructor냐 named constructor냐 하는 정도.

여기서는 overflow 방지를 위해 Image widget을Expanded widget으로 감쌌다. 양 옆의 IconButton widget들이 공간을 차지하고 남은 공간에 이미지를 그리도록 한 것이다.

수업과 다른 부분

  • Icons.arrow_back_ios_new
    • Icons.arrow_back_ios 는 우측에 Icons.arrow_forward_ios 를 함께 사용할 것을 전제로 만들어져 좌측으로 치우쳐 있다. 단독 사용을 위해 추가된 아이콘을 사용하여 좌우 여백에 통일감을 주었다.

part 03: 부연 설명이 들어가는 곳

운동 내용과 추천 대상을 나란히 보여주는 영역을 작성한다. Row widget에 Container widget 두 개를 넣고, 각각의 Container widget 안에 Column widget으로 텍스트를 배치할 것이다.

lib/workout_guide_page.dart

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Workout Guide'), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            // ...생략: 사진이 들어가는 곳
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              Container(
                width: 160,
                height: 100,
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey.shade300),
                  borderRadius: BorderRadius.all(Radius.circular(16)),
                ),
                child: Column(
                  children: [
                    Text(
                      '운동 내용',
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    Text(
                      '배, 상체 근육',
                      style: TextStyle(fontSize: 18, color: Colors.grey),
                    ),
                  ],
                ),
              ),
              Container(
                width: 160,
                height: 100,
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey.shade300),
                  borderRadius: BorderRadius.all(Radius.circular(16)),
                ),
                child: Column(
                  children: [
                    Text(
                      '이런 사람에게 강추!',
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    Text(
                      '뱃살이 고민이에요\n체지방 태우고 싶어요',
                      style: TextStyle(fontSize: 14, color: Colors.grey),
                      textAlign: TextAlign.center,
                    ),
                  ],
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

여기서는 폰트 크기를 조정하여 overflow를 해결했지만, 실무 관점에서는 좀 더 고민하여 반응형으로 구현하는 게 좋다. 폰트뿐만 아니라 다른 widget들에 대해서도 이를 고려하여 작성하는 게 좋지만 지금은 이대로 넘어가자. 당장은 큰 부분만 고려하고 사용자가 늘어났을 때 확장하는 게 개발 비용과의 타협점이라고 볼 수도 있고.

part 04: 소요 시간이 들어가는 곳

이것은 [part 03]에서 작성한 Container 를 가져다가 수정할 수 있다. 이번에는 너비를 하드코딩하지 않고 Expanded 로 처리할 것이다.

lib/workout_guide_page.dart

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Workout Guide'), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            // ...생략: 사진이 들어가는 곳
          ),
          Row(
            // ...생략: 부연 설명이 들어가는 곳
          ),
          Row(
            children: [
              Expanded(
                child: Container(
                  padding: EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey.shade300),
                    borderRadius: BorderRadius.all(Radius.circular(16)),
                  ),
                  child: Text(
                    '30분',
                    style: TextStyle(
                      fontSize: 24,
                      fontWeight: FontWeight.bold,
                      color: Colors.blue,
                    ),
                    textAlign: TextAlign.center,
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

part 05: 버튼이 들어가는 곳

버튼은 [part 02]에서 화살표 버튼을 생성했던 것처럼 IconButton widget을 사용한다.

lib/workout_guide_page.dart

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Workout Guide'), centerTitle: true),
      body: Column(
        children: [
          Row(
            // ...생략: 제목이 들어가는 곳
          ),
          Row(
            // ...생략: 사진이 들어가는 곳
          ),
          Row(
            // ...생략: 부연 설명이 들어가는 곳
          ),
          Row(
            // ...생략: 소요 시간이 들어가는 곳
          ),
          IconButton(
            onPressed: () {},
            icon: Icon(Icons.play_circle, color: Colors.blue,),
            iconSize: 70,
          ),
        ],
      ),
    );
  }
}

part 06: 레이아웃 맞추기

전체 widget을 감싸는 Column widget에 mainAxisAlignment: MainAxisAlignment.spaceAround, 도 넣고 Padding 으로도 감싸고 보기 좋게 만든다.

이미지 채우기

이미지에서만 쓰는 건 아니지만 이미지랑 같이 쓰일 때가 많다. 간단히 알아만 두자.

BoxFit widget

설명
BoxFit.fill비율을 무시하고 가득 차도록 채워 넣는다
BoxFit.contain비율을 유지한 채 잘리지 않게 넣는다 (여백 남음)
BoxFit.cover비율을 유지한 채 여백이 남지 않게 채운다
BoxFit.fitWidth비율을 유지한 채 높이가 넘치든 모자라든 너비를 기준으로 채운다
BoxFit.fitHeight비율을 유지한 채 너비가 넘치든 모자라든 높이를 기준으로 채운다
BoxFit.none크기 변경 없이 가운데 배치하여 넘칠 수도 있고 여백이 남을 수도 있다
BoxFit.scaleDown여백이 남으면 원본을 크기를 유지하고 넘치면 contain 처럼 줄인다

Package 활용

모든 코드를 직접 구현하지는 않는다. 공개되어 있는 외부 package를 사용하는 경우도 많다. Flutter에서 사용할 package를 찾을 땐 보통은 Google에서 운영하는 pub.dev에서 탐색하고, 경우에 따라 이곳에 없는 마이너한 repository를 GitHub 같은 데서 찾아 쓰기도 한다.

검색해서 나오는 package의 메타데이터를 보고, target platform이 적절한지, 유지보수가 잘 되고 있는지, 수요가 많은지 등을 판단하여 어떤 것을 사용할지 정할 수 있다.

본격적으로 package를 사용하는 실습은 다음 시간에 할 것이다. 일단은 package는 import keyword로 불러오며, show, hide keyword를 통해 package 내부 요소를 선택적으로 가져올 수도 있다는 것만 알아두자. 선택적으로 가져오는 건 보통 충돌 방지를 목적으로 하는 경우가 많다나.

package version은 주로 a.b.c 형식으로 작성되는데, [major version].[minor version].[patch version] 을 의미한다.

semantic version

versiondescription
major큰 기능이 변경되어 기존과 호환 불가능할 때 올린다
minor호환성을 유지하며 신규 기능을 추가할 때 올린다
patch기존 기능의 버그만 수정할 때 올린다

모든 프로젝츠가 이런 semantic versioning을 사용하는 건 아니다. 우리가 사용하는 Android Studio만 해도 2026.1.2 Patch 1 라는 버전을 사용한다. 반년마다 새 메이저 버전이 올라오는 Ubuntu는 26.04 와 같이 몇 년 몇 월에 출시된 버전인지 나타내기도 하고 말이다.

오늘의 실습

WorkoutListPage ChallengeWorkoutGuidePage Challenge 를 해 보았다. 이에 대한 내용은 DAY 12-1에서 확인할 수 있다.

그리고 남은 시간에는 프로젝트와 이것저것이것저것이것저것이 나를 기다리고 있다.

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

0개의 댓글