[Flutter] 스나이퍼팩토리 27일차

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

스나이퍼팩토리 플러터 27일차

27일차에서는 Map 데이터 안에 또 Map이 있는 구조의 데이터를 Serialization 하는 방법에 대해 학습했다.
(26일차는 3.1절로 포스팅이 없습니다.)

학습한 내용

  • 프로필 앱 만들기

추가 내용 정리

Dart Interface (인터페이스)

일반적으로 다른 언어들에서는 인터페이스를 정의하기 위해 interface와 같은 키워드를 사용하지만 Dart는 따로 키워드 없이 클래스로 만든다.

이를 구현하기 위해서는 implements 키워드를 사용한다. 아래는 인터페이스의 예시 코드이다.

class Animal {
	String? name;
    
    Animal(this.name);
    
    void sayName() {
    	print('My name is ${this.name}');
    }
}

class Cat implements Animal {
	// Animal 클래스에 정의되어있기 때문에 반드시 구현!
	String? name;  
    
    // 상속이 아니기 때문에 super를 사용하지 않음
    Cat(this.name);
    
    // 상속과 달리 구현하지 않으면 에러 발생
	void sayName() {
    	print('고양이');
    }
}

void main() {
	var cat = Cat('고양이');
    cat.sayName();
}

인터페이스와 추상 클래스

인터페이스는 추상 클래스와 비슷해 보이지만 약간의 차이가 있다.

추상 클래스에 대한 내용은 [Flutter] 스나이퍼팩토리 25일차 part1에서 정리했다.

추상 클래스는 상속을 통해서 추상 메서드를 정의하도록 유도하는 미완성 설계도이고, 인터페이스는 기본 설계도이다.

간단히 말하면 인터페이스는 모든 메소드가 추상 메서드로 구현되지 않은 상태이고, 추상 클래스는 일반 메서드도 추상 메서드와 함께 사용될 수 있다.

물론 추상 클래스에서 모든 메서드를 추상 메서드로 구현할 수도 있지만 인터페이스와 사용 용도에 맞게 사용하면 더 좋을 것 같다.

추상 클래스와 인터페이스의 목적
추상 클래스: 상속을 받아서 기능을 확장 시키는 것
인터페이스: 구현하는 모든 클래스에 대해 특정한 메서드가 반드시 존재하도록 강제하는 역할


27일차 과제

  1. 프로필 앱 만들기
  2. 추가 사항

1. 프로필 앱 만들기

공개된 API를 분석하고, 클래스를 활용하여 적용 후 유저들의 프로필을 보여주는 앱을 만들고자 한다.

요구사항

  • 아래의 공개된 API에서 데이터를 받아온다.
  • 각 사람별 이미지를 CircleAvatar를 통해 보여주도록 한다.
  • 애니메이션 효과를 적절히 사용해 최대한 결과물 예시와 비슷하게 만든다.
  • 네트워크에 통신하여 데이터를 가져오는 것은 첫 페이지(리스트를 보여주는 페이지)에서만 할 수 있도록 한다.

결과물 예시

코드 작성

  • pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  dio: ^5.0.0
  cached_network_image: ^3.2.3
  animate_do: ^3.0.2

pubspec.yaml에 사용할 패키지들을 설치했다.

  • lib/main.dart
import 'package:flutter/material.dart';
import 'package:my_app/page/main_page.dart';

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

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

  // root Widget
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MainPage(), // 메인 페이지 호출
    );
  }
}

main.dart에서는 MainPage를 호출한다.

  • lib/model/user.dart
import 'package:my_app/model/address.dart';
import 'package:my_app/model/company.dart';

class User {
  int id; //아이디
  String name; //이름
  String email; //이메일
  Address address; //주소
  String phone; //전화번호
  String website; //웹사이트
  Company company; //회사

  //생성자
  User({
    required this.id,
    required this.name,
    required this.email,
    required this.address,
    required this.phone,
    required this.website,
    required this.company,
  });

  //데이터를 직렬화하여 User 객체를 생성
  factory User.fromMap(Map<String, dynamic> map) {
    return User(
      id: map['id'],
      name: map['name'],
      email: map['email'],
      address: Address.fromMap(map['address']),
      phone: map['phone'],
      website: map['website'],
      company: Company.fromMap(map['company']),
    );
  }
}

User 클래스는 사용자의 전체적인 정보를 담고 있다. 주소와 회사는 다른 클래스로 작성하여 각각 AddressCompany 객체를 사용한다.

  • lib/model/company.dart
class Company {
  String name; //회사 이름
  String catchPhrase; //회사 문구
  String bs; //직무

  //생성자
  Company({
    required this.name,
    required this.catchPhrase,
    required this.bs,
  });

  //데이터를 직렬화하여 Company 객체를 생성
  factory Company.fromMap(Map<String, dynamic> map) {
    return Company(
      name: map['name'],
      catchPhrase: map['catchPhrase'],
      bs: map['bs'],
    );
  }
}

Company 클래스는 사용자의 회사 정보를 가지고 있다.

  • lib/model/address.dart
import 'package:my_app/model/geo.dart';

class Address {
  String street; //스트리트
  String suite; //거주지
  String city; //도시
  String zipcode; //우편번호
  Geo geo; //지리적 주소

  //생성자
  Address({
    required this.street,
    required this.suite,
    required this.city,
    required this.zipcode,
    required this.geo,
  });

  //데이터를 직렬화하여 Address 객체를 생성
  factory Address.fromMap(Map<String, dynamic> map) {
    return Address(
      street: map['street'],
      suite: map['suite'],
      city: map['city'],
      zipcode: map['zipcode'],
      geo: Geo.fromMap(map['geo']),
    );
  }
}

Address 클래스는 사용자의 주소 정보를 담고 있다. Geo는 다른 클래스로 작성했다.

  • lib/model/geo.dart
class Geo {
  String lat; //위도
  String lng; //경도

  //생성자
  Geo({
    required this.lat, 
    required this.lng, 
  });

  //데이터를 직렬화하여 Geo 객체를 생성
  factory Geo.fromMap(Map<String, dynamic> map) {
    return Geo(
      lat: map['lat'],
      lng: map['lng'],
    );
  }
}

Geo 클래스는 위도와 경도의 정보를 저장한다.

  • lib/page/main_page.dart
import 'package:animate_do/animate_do.dart';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:my_app/model/user.dart';
import 'package:my_app/widget/contact_tile.dart';

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

  
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  final Dio _dio = Dio(); //dio 객체
  final String _url = 'https://jsonplaceholder.typicode.com/users'; //데이터 요청 url
  Future<List<User>>? _result; //데이터 불러오기 결과

  //데이터 불러오기
  Future<List<User>> _readData() async {
    var response = await _dio.get(_url); //데이터 요청

    //데이터를 성공적으로 받아온 경우
    if (response.statusCode == 200) {
      var data = List<Map<String, dynamic>>.from(response.data); //결과 형변환
      return data.map((e) => User.fromMap(e)).toList(); //데이터 직렬화 후 반환
    }
    return []; //빈 리스트 반환
  }

  
  void initState() {
    super.initState();
    _result = _readData(); //네트워크 데이터 초기화
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        foregroundColor: Colors.black,
        backgroundColor: Colors.transparent,
        elevation: 0,
        centerTitle: true,
        title: const Text('My Contacts'),
        flexibleSpace: GestureDetector(
          //앱바를 눌렀을 때 데이터를 다시 받아와 새로고침
          onTap: () => setState(() {
            _result = _readData();
          }),
        ),
      ),
      body: FutureBuilder(
        future: _result, //네트워크 데이터 결과 바인딩
        builder: (context, snapshot) {
          //데이터를 받아오는 중에는 로딩 화면 출력
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          //데이터를 받아오지 못했거나 없는 경우 결과 텍스트 출력
          if (!snapshot.hasData || snapshot.data == null) {
            return const Text('데이터가 없습니다!');
          }

          //데이터를 성공적으로 받아온 경우 리스트뷰를 출력
          return ListView.builder(
            physics: const BouncingScrollPhysics(),
            itemCount: snapshot.data?.length ?? 0,
            itemBuilder: (context, index) {
              return FadeInRight(
                delay: Duration(milliseconds: index * 300),
                //각 사용자의 정보를 커스텀 타일로 출력
                child: ContactTile(
                  user: snapshot.data![index],
                  image:
                      'https://xsgames.co/randomusers/assets/avatars/male/${snapshot.data![index].id}.jpg',
                ),
              );
            },
          );
        },
      ),
    );
  }
}

MainPage에서는 우선 데이터를 받아오는 _readData()를 작성했다. 이 메소드는 initState()에서 초기화 했다.

앱바에서 GestureDetector를 사용해 눌렀을 때 데이터를 다시 받아와 화면을 새로고침했다.

본문은 FutureBuilder를 사용해 그려주었고 데이터를 가져온 경우 리스트뷰를 출력했다. 리스트 뷰의 각 요소는 animate_do패키지의 FadeInRight 애니메이션을 사용했다. 각 요소의 delay를 다르게 설정하여 순차적으로 사용자 정보들이 출력되게 했다.

각 사용자 정보는 ContactTile을 직접 작성하여 사용했다.

import 'package:animate_do/animate_do.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:my_app/model/user.dart';
import 'package:my_app/widget/company_tile.dart';
import 'package:my_app/widget/information_tile.dart';

class ContactDetailPage extends StatelessWidget {
  const ContactDetailPage({super.key, required this.user, required this.image});

  final User user; //사용자 정보
  final String image; //이미지 URL

  
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true, //body 확장
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        centerTitle: true,
        title: Text(user.name),
      ),
      body: Stack(
        children: [
          Column(
            children: [
              //사용자 이미지 배경
              Container(
                height: 250,
                decoration: BoxDecoration(
                  image: DecorationImage(
                    fit: BoxFit.cover,
                    //이미지 어둡게
                    colorFilter: const ColorFilter.mode(
                      Colors.black54,
                      BlendMode.darken,
                    ),
                    image: CachedNetworkImageProvider(image),
                  ),
                ),
              ),
              const SizedBox(height: 50),
              SingleChildScrollView(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      //사용자 이름
                      FadeIn(
                        delay: const Duration(milliseconds: 300),
                        child: Text(
                          style: const TextStyle(
                            color: Colors.grey,
                            fontSize: 32,
                          ),
                          user.name,
                        ),
                      ),
                      const Divider(height: 32),
                      //사용자 정보
                      FadeIn(
                        delay: const Duration(milliseconds: 800),
                        child: InformationTile(user: user),
                      ),
                      const Divider(height: 32),
                      //사용자 회사 정보
                      FadeIn(
                        delay: const Duration(milliseconds: 1300),
                        child: CompanyTile(company: user.company),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
          //원형 이미지
          Positioned(
            top: 200,
            left: 16,
            child: CircleAvatar(
              radius: 50,
              backgroundImage: CachedNetworkImageProvider(image),
            ),
          )
        ],
      ),
    );
  }
}

MainPage에서 클릭한 사용자의 정보를 보여주는 페이지인 ContactDetailPage는 사용자 정보와 사용자의 이미지 URL을 전달받는다.

바디의 내용을 확장하여 사용자의 이미지를 상단에 넣어주었다. 이 때 이미지는 약간 어둡게 처리했다.

그리고 Column으로 사용자의 이름과 사용자의 정보, 사용자의 회사 정보를 각각 출력했다. 사용자의 정보는 InformationTile로, 사용자의 회사 정보는 CompanyTile로 커스텀 위젯을 작성하여 사용했다. 세 가지 정보는 FadeIn 애니메이션을 적용했고, 각각 delay를 다르게 설정했다.

전체를 Stack으로 감싸고 Positoned를 사용해 사용자의 원형 이미지를 배경 이미지와 사용자 정보 사이에 넣어 주었다.

  • lib/widget/contact_tile.dart
import 'package:flutter/material.dart';
import 'package:my_app/model/user.dart';
import 'package:my_app/page/contact_detail_page.dart';
import 'package:cached_network_image/cached_network_image.dart';

class ContactTile extends StatelessWidget {
  const ContactTile({super.key, required this.user, required this.image});

  final User user; //사용자 정보
  final String image; //사용자 이미지 URL

  //페이지 이동 라우트 생성
  Route _createRoute(Widget page) {
    return PageRouteBuilder(
      pageBuilder: (context, animation, secondaryAnimation) => page,
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        var begin = const Offset(1.0, 0.0); //오른쪽 위 시작 지점
        var end = Offset.zero; //왼쪽 위 끝 지점
        var curve = Curves.ease;

        var tween =
            Tween(begin: begin, end: end).chain(CurveTween(curve: curve));

        return SlideTransition(
          position: animation.drive(tween),
          child: child,
        );
      },
    );
  }

  
  Widget build(BuildContext context) {
    return GestureDetector(
      //상세 프로필 페이지로 이동
      onTap: () => Navigator.of(context).push(
        _createRoute(ContactDetailPage(user: user, image: image)),
      ),
      child: ListTile(
        title: Text(user.name), //사용자 이름
        subtitle: Text(user.email), //사용자 이메일
        //사용자 이미지
        leading: CircleAvatar(
          backgroundImage: CachedNetworkImageProvider(image),
        ),
        trailing: const Icon(Icons.navigate_next),
      ),
    );
  }
}

ContactTileMainPage에서 각 사용자의 정보를 보여주는 타일이다. 사용자의 정보와 이미지 url을 전달받는다.

페이지 이동 라우트를 생성하는 함수를 _createRoute로 작성하고, 해당 타일을 눌렀을 때 상세 프로필 페이지로 이동하도록 onTap 이벤트를 작성했다.

각 정보는 ListTile을 사용해 출력했고, 이미지는 CircleAvatarCachedNetworkImageProvider를 사용했다.

  • lib/widget/information_tile.dart
import 'package:flutter/material.dart';
import 'package:my_app/model/user.dart';

class InformationTile extends StatelessWidget {
  const InformationTile({super.key, required this.user});

  final User user; //사용자 정보

  
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          style: TextStyle(
            fontSize: 24,
          ),
          'Information',
        ),
        const SizedBox(height: 8),
        //사용자 이메일
        Row(
          children: [
            const Icon(Icons.mail),
            const SizedBox(width: 8),
            Text(user.email),
          ],
        ),
        const SizedBox(height: 8),
        //사용자 전화번호
        Row(
          children: [
            const Icon(Icons.call),
            const SizedBox(width: 8),
            Text(user.phone),
          ],
        ),
        const SizedBox(height: 8),
        //사용자 주소
        Row(
          children: [
            const Icon(Icons.location_on),
            const SizedBox(width: 8),
            Text(
                '${user.address.city}, ${user.address.street}, ${user.address.suite}'),
          ],
        ),
      ],
    );
  }
}

InformationTile은 사용자의 정보를 전달받아 사용자의 이메일과 사용자의 전화번호, 사용자의 주소를 출력한다.

상세 프로필 페이지에서 사용된다.

  • lib/widget/company_tile.dart
import 'package:flutter/material.dart';
import 'package:my_app/model/company.dart';

class CompanyTile extends StatelessWidget {
  const CompanyTile({super.key, required this.company});

  final Company company; //사용자 회사 정보

  
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text(
          style: TextStyle(
            fontSize: 24,
          ),
          'Company',
        ),
        const SizedBox(height: 8),
        Text(company.name), //회사 이름
        const SizedBox(height: 8),
        Text(company.catchPhrase), //회사 문구
        const SizedBox(height: 8),
        Text(company.bs), //회사 직무
      ],
    );
  }
}

CompanyTile은 사용자의 회사 정보를 전달받아 출력한다.

상세 프로필 페이지에서 사용된다.

결과

2. 추가 사항

과제를 수행하고 난 뒤 강의를 듣고 아쉬웠던 점을 정리했다.

Stack의 clipBehavior 속성

코드를 작성할 때 ContactDetailPage에서 사용자의 원형 이미지가 배경 이미지와 Stack으로 묶으면 아래 부분이 잘려서 사용자 정보와 함께 전체를 Stack으로 사용했는데 배경 이미지와만 묶고 clipBehavior속성을 설정하면 잘린 부분도 보이게 할 수 있다. 또한 Positioned에서 bottom의 값을 설정한 radius의 값의 음수를 적용하면 절반만 아래에 걸치게 할 수 있다.

위의 코드에서는 이미지를 약간 어둡게 하기 위해 Containerdecoration속성에서 이미지를 넣고 colorFilter를 적용했는데 이렇게 하지 않고 Image.network 위젯에서 바로 colorBlendMode 속성을 사용하고 color를 설정하면 어둡게 처리할 수 있다.

아래는 이러한 수정사항을 모두 적용한 코드이다.

Stack(
	clipBehavior: Clip.none,
    children: [
      Image.network(
          fit: BoxFit.cover,
          width: double.infinity,
          height: 250,
          colorBlendMode: BlendMode.darken,
          color: Colors.black54,
          image
      ),
      Positioned(
          bottom: -50,
          child: CircleAvatar(
              radius: 50,
              backgroundImage: NetworkImage(image),
          ),
      ),
   ],
),
    

Stack의


3.1절 쉬고 돌아왔습니다 😊😊

어제가 3.1절이라 쉬고 돌아왔습니다 ㅎㅎ (그래서 26일차 포스팅이 없고 바로 27일차로) 오늘도 역시 데이터 Serialization 연습을 했다. 이번에는 Map형태의 데이터 안에 또 Map 형태가 들어있는 것을 직렬화 했는데 클래스를 여러개 만들어서 데이터가 매핑되는 것이 뭔가 신기했다ㅋㅋㅋㅋ 어쨌든 이번 과제도 잘 마무리했고, 추가 내용 정리는 간단하게 인터페이스에 대해 정리했다. 다트에서는 인터페이스에 사용되는 키워드가 따로 없어서 약간 당황했다. 추상 클래스와 거의 비슷한데 사용 방식에 차이가 있어서 코드를 짤 때 두 가지를 잘 구분해서 사용해야 될 것 같다ㅎㅎ

📄Reference

profile
관우로그

0개의 댓글

관련 채용 정보