[Flutter] 스나이퍼팩토리 2주차 도전하기

KWANWOO·2023년 2월 5일
1
post-thumbnail

스나이퍼팩토리 플러터 2주차 도전하기

Flutter로 UI 그리기 연습

1. UI 설명

  • 플러터를 사용해 아래와 같은 이미지의 결과가 나오게 UI를 그린다.
  • 이미지, 텍스트, 아이콘 등의 내용은 임의로 변경하여 사용했다.

2. UI 그리기

  • 코드

FriendTile.dart

import 'package:flutter/material.dart';

class FriendTile extends StatelessWidget {
  const FriendTile(
      {super.key,
      required this.title,
      this.subtitle,
      required this.imgUrl,
      this.musicName});

  final String title; // 친구 이름
  final String? subtitle; // 친구 상태 메세지
  final String imgUrl; // 친구 이미지 url
  final String? musicName; // 친구 음악 제목

  
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(title),
      subtitle: subtitle != null ? Text(subtitle!) : null, //상태 메세지가 있을 경우 출력
      leading: ClipRRect(
        borderRadius: BorderRadius.circular(11),
        child: Image.network(imgUrl),
      ),
      //음악 제목이 있을 경우 Container 생성
      trailing: musicName != null
          ? Container(
              padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
              decoration: BoxDecoration(
                border: Border.all(
                  width: 2,
                  color: Colors.green,
                ),
                borderRadius: BorderRadius.circular(20),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(musicName!),
                  Icon(color: Colors.green, Icons.play_arrow_outlined),
                ],
              ),
            )
          : null,
    );
  }
}

main.dart

import 'package:flutter/material.dart';
import 'package:my_app/FriendTile.dart';

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

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

  // root Widget
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(title: '친구'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title; //앱 제목

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        foregroundColor: Colors.black,
        elevation: 0,
        title: Text(title),
        actions: [
          Icon(Icons.search),
          SizedBox(width: 8),
          Icon(Icons.person_add_alt),
          SizedBox(width: 8),
          Icon(Icons.music_note_outlined),
          SizedBox(width: 8),
          Icon(Icons.settings),
          SizedBox(width: 8),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                //광고 배너
                Container(
                  height: 64,
                  width: MediaQuery.of(context).size.width,
                  alignment: Alignment.centerLeft,
                  padding: EdgeInsets.only(left: 16),
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(8),
                    image: DecorationImage(
                      image: AssetImage('assets/images/blue_circle.jpg'),
                      fit: BoxFit.cover,
                    ),
                  ),
                  child: RichText(
                    text: TextSpan(
                      children: [
                        TextSpan(
                          style: TextStyle(
                            fontSize: 15,
                            fontWeight: FontWeight.bold,
                            color: Colors.white,
                          ),
                          text: 'SFAC 플러터 개발자 양성프로젝트\n',
                        ),
                        TextSpan(
                          style: TextStyle(
                            fontSize: 12,
                            color: Colors.grey,
                          ),
                          text: '체계적인 수강생 관리 및 코칭',
                        ),
                      ],
                    ),
                  ),
                ),
                SizedBox(height: 16),
                //내 정보
                Row(
                  children: [
                    ClipRRect(
                      borderRadius: BorderRadius.circular(16),
                      child: Image.network('https://picsum.photos/50/50'),
                    ),
                    SizedBox(width: 12),
                    Text(
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 16,
                      ),
                      'SFAC',
                    ),
                  ],
                ),
                SizedBox(height: 8),
                Divider(thickness: 1), // 구분 선
                //친구 수 텍스트
                Text(
                  style: TextStyle(
                    color: Colors.grey,
                    fontWeight: FontWeight.bold,
                  ),
                  '친구 7',
                ),
              ],
            ),
          ),
          //친구 리스트 뷰
          Expanded(
            child: ListView(
              children: [
                FriendTile(
                  title: '전여친',
                  subtitle: '똥차다음벤츠라더니',
                  imgUrl: 'https://picsum.photos/45/45',
                  musicName: '똥밟았네',
                ),
                FriendTile(
                  title: '우리형',
                  subtitle: '40000/24000',
                  imgUrl: 'https://picsum.photos/45/45',
                  musicName: 'Hype boy',
                ),
                FriendTile(
                  title: '마이클',
                  imgUrl: 'https://picsum.photos/45/45',
                ),
                FriendTile(
                  title: '친구1',
                  subtitle: '열차예약~',
                  imgUrl: 'https://picsum.photos/45/45',
                  musicName: '봄여름가을겨울',
                ),
                FriendTile(
                  title: '민석이',
                  imgUrl: 'https://picsum.photos/45/45',
                ),
                FriendTile(
                  title: '친구2',
                  subtitle: '피곤...',
                  imgUrl: 'https://picsum.photos/45/45',
                ),
                FriendTile(
                  title: '친구3',
                  imgUrl: 'https://picsum.photos/45/45',
                  musicName: 'Ditto',
                ),
              ],
            ),
          )
        ],
      ),
      // 하단 바
      bottomNavigationBar: BottomNavigationBar(
        showSelectedLabels: false,
        showUnselectedLabels: false,
        selectedItemColor: Colors.black,
        type: BottomNavigationBarType.fixed,
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'person'),
          // 채팅 아이콘에 알림 뱃지 추가
          BottomNavigationBarItem(
              icon: Stack(
                clipBehavior: Clip.none,
                children: [
                  Icon(Icons.chat_bubble_outline),
                  Positioned(
                    right: -10,
                    top: -5,
                    child: Container(
                      padding: EdgeInsets.all(2),
                      alignment: Alignment.center,
                      decoration: BoxDecoration(
                        color: Colors.red,
                        borderRadius: BorderRadius.circular(4),
                      ),
                      constraints: BoxConstraints(
                        minHeight: 12,
                        minWidth: 12,
                      ),
                      child: Text(
                        '99+',
                        style: TextStyle(
                          color: Colors.white,
                          fontSize: 10,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
              label: 'chat'),
          BottomNavigationBarItem(
              icon: Icon(Icons.visibility_outlined), label: 'visible'),
          BottomNavigationBarItem(icon: Icon(Icons.storefront), label: 'store'),
          BottomNavigationBarItem(icon: Icon(Icons.more_horiz), label: 'more'),
        ],
      ),
    );
  }
}
  • 결과

같은 UI를 제작하는 것을 목표로 하여 없는 아이콘과 이미지는 임의로 비슷한 것으로 대체하였다. 친구는 7명 정도를 생성했으며, 친구의 사진 이미지는 같은 url을 사용하여 모두 같게 나왔지만 다른 url을 입력하면 각각 설정이 가능하다.

FriendTile.dart

친구들의 정보를 저장할 ListTile을 따로 커스텀 위젯으로 작성해 주었다. 입력받을 변수들은 title subtitle imgUrl musicName 네 가지로 각각 친구 이름, 상태 메세지, 이미지 url, 음악 제목을 의미하며 titleimgUrl만 필수 항목으로 설정했다.

ListTile에서 subtitletrailing은 값이 없을 경우 그리지 않아야 하므로 전달받은 subtitlemusicName이 null인지 확인하고 그리도록 했다.

trailingContainerdecoration으로 테두리 색과 곡선을 적용해 UI를 구성했다.

main.dart

MyHomePage 위젯에서는 우선 앱바를 생성하고 actions 속성으로 4개의 아이콘을 넣었다.

본문은 전체를 Column으로 생성하고 리스트뷰 전까지의 요소를 다시 PaddingColumn으로 묶어 여백을 적용했다.

광고 배너의 배경은 비슷한 이미지를 다운받아 사용했으며 child로 RichText 를 사용해 텍스트를 넣었다.

내 사진과 정보는 하나의 Row로 구성했으며, 아래에는 Divider로 구분 선을 그렸다. 구분 선 아래에는 친구의 수를 표현하는 텍스트를 넣었는데 7명 정도로 만들었다.

친구들의 리스트는 ListView를 사용했으며 각각의 요소는 앞에서 만든 FriendTile을 사용했다. 여기서 titleimgUrl은 필수로 작성해야 하고, subtitlemusicName은 필요한 경우에만 작성해 주었다.

하단 네비게이션 바는 5가지 요소를 넣었는데 머티리얼 디자인에 완벽하게 같은 아이콘이 없는 경우 비슷한 아이콘을 사용했다.

하단 네비게이션 바의 채팅 아이콘에는 빨간색 알림 뱃지를 넣었는데 Stack 위젯을 사용해 아이콘과 뱃지를 쌓아주었고, 뱃지는 Positioned 로 위치를 설정한 뒤 ContainerText 위젯을 사용해 구성했다.

3. 추가 내용 정리

?! (Null Safety)

제목이 당황스럽지만 이 파트에서는 Null Safety에 대해서 알아보고자 한다. Flutter 2.0 부터 Null Safety(널 안정성)가 적용되었다. 이는 null로 인해 의도하지 않거나 예상치 않은 동작에 대비하는 것에 목적이 있다.

Null Safety 이후에 기본적으로 모든 데이터타입은 null을 허용하지 않는 Non-nullable type이다. 데이터를 Nullable type으로 선언하고 싶다면 데이터 타입 뒤에 ?를 사용하면 된다.

void main() {
	int? a // nullable type
    a = null
}

Nullable type의 변수를 선언하면 null check를 해야 하는데 아래와 같이 세 가지 방법 정도가 있다.

  1. if-else 문 사용
void main() {
	String? str;
    str = null;
    
    if (str == null) {
    	print("empty");
    } else {
    	print(str);
    }
}
  1. 삼항연산자 사용
void main() {
	String? str;
    str = null;
    
    print(str == null ? "empty" : str);
}
  1. ?? 연산자 사용: 변수가 null이면 ?? 뒤의 값으로 대체
void main() {
	String? str;
    str = null;
    
    print(str ?? "empty");
}

null check를 해서 오류를 막아야 하지만 nullable type의 변수를 선언하고 이 변수에는 절대 null이 대입되지 않는다고 100% 확신이 들 수 있다. 이 경우에는 null assertion 연산자를 사용할 수 있다. 사용 방법은 변수 뒤에 !를 붙이면 된다.

아래와 같은 코드는 a가 nullable type 이지만 3이라는 값이 대입되어 절대 null이 아니기 때문에 c에 대입할 때 뒤에 !를 붙여 오류를 막을 수 있다.

void main() {
	int? a = 3;
    int c = a!;
}

Non-nullable type의 매개변수가 포함된 함수를 선언하려면 매개변수의 값을 초기화 해주거나 required 키워드를 사용하면 된다.

class Person {
	String name;
    int? birthYear;
    int money;
    
    Person({required this.name,
    		this.birthYear,
            this.money = 0});
}

void main() {
	Person p1 = Person(name: "Kim");
    print(p1.name);
    print(p1.birthYear ?? "알 수 없음");
    print(p1.money);
}

또 다른 방법으로 변수를 당장 초기화 하고 싶지 않은데 nullable type으로 선언하고 싶지도 않다면 late 키워드를 사용하면 된다.

class Meal {
	late String description;
    
    void setDescription(String str) {
    	description = str;
    }
}

void main() {
	final myMeal = Meal();
    myMeal.setDescription('pizza');
    print(myMeal.description);
}

Stack Widget과 Positioned Widget

Stack은 위젯을 서로 겹치게 쌓아 배치하고 싶을 때 사용하는 위젯이다.

Stack위젯은 다른 위젯들과 마찬가지로 배치하고자 하는 위젯들을 감싸고 children으로 생성해 주면 된다.

위젯의 위치를 변경하고 싶을 때는 Positioned 위젯을 사용하여 감싸주면 된다. 속성으로 가로, 세로 위치를 지정해주고, child로 배치하려는 위젯을 넣어주면 된다.

Stack의 특징은 맨 마지막에 생성된 위젯이 가장 위로 온다는 점이다.

자세한 사용 방법은 아래의 링크를 참고
[Flutter] Stack과 Positioned Class


도전과제 완료!!

도전과제는 만들어야할 UI를 이미지로 제공해 주시고 똑같이 구현해 보는 것인데 요구사항이나 정해진 것이 없어서 좋으면서도 내 맘대로 약간 수정이 들어가다 보니 이렇게 해도 되나? 싶어서 약간 헷갈린다.. ㅋㅋ 어쨌뜬 2주차 도전과제도 마무리 했다. 원래 ListView에 들어갈 데이터들을 리스트로 생성하고 ListView.builder를 사용해 보려 했는데 UI만 구성하면 될꺼 같아서 기존에 해봤던 방법대로 커스텀 위젯을 만들어서 ListView로 해봤다. 친구 수는 7명으로 대폭 줄여서 만들었는데 괜찮겠지...? 하단 바의 [99+] 알림 뱃지는 하지 않아도 된다고 하셨는데 검색해서 해결해 봤다. ㅎㅎ

📄 Reference

profile
관우로그

0개의 댓글

관련 채용 정보