기기 위치(위도, 경도)를 받아 Vworld에서 api요청으로 해당 좌표의 주소를 받아오는 앱을 구현 중,

 

국내의 유효한 좌표를 입력해도 유효한 주소를 받을 수 없는 문제가 지속적으로 발생했다. 

 

함수의 어느 부분에서 코드가 생각대로 실행되지 않는지 확인하기 위해 진행상황을 확인하는 print() 문을 사이사이에 넣었다.

 

디버그 콘솔 결과

해당 좌표는 경복궁 좌표.

 

 

찾아보니 산 같은 곳은 주소가 제대로 잡히지 않는 경우가 있다고 해서 확실히 주소가 있을, 건물 위주로 좌표를 바꿔가며 시도해 보았지만 여전히 3번 단계에서부터 null 값이 출력되었다.

 

 

뭔가 놓치고 있는 점이 있나 확인하기 위해 vworld에서 api레퍼런스를 찾아보았다.

https://www.vworld.kr/dev/v4dv_search2_s001.do

지금까지 road유형을 사용했으니 parcel 유형을 시도해보았다.

 

 

허무할 정도로 잘 실행되는 모습

 

좌표를 도로 주소로 받아오는 데 api상의 문제가 있는 모양이다. 좌표를 지번 주소로 받아오는 것은 깔끔하게 잘 실행되었다.

 

 

다만, 이 이후에는 검색어가 너무 길어 검색이 잘 이루어지지 않는 문제가 있었다. 

이것은 주소 데이터 중 '동', '읍', '면' 이 포함된 어절을 사용해 검색하는 것으로 해결했다.

 

 

++ 25.04.21 수정

 

기존에는 브이월드 검색 api를 사용하였으나 관련 강의를 듣던 중 2D 데이터 api - '읍면동'이 더 적합하다는 것을 알게 되었다. 

리퀘스트와 기타 파라미터들을 변경해 해당 api를 불러왔고, 결과적으로 검색어 후처리를 하지 않아도 읍, 면, 동 단위의 검색이 가능하게 되었다.

 

수정 코드

MVVM 방식, 네이버 오픈 API를 사용한 아주 간단한 앱을 구현했다.

복잡한 기능은 모두 빼고, 위 두 가지 기능의 실제 사용에 집중했다.

 

사용한 패키지: flutter_riverpod, http

 

 

결과화면

 

페이지 하나로 구성된 앱으로, 검색어를 입력하면 네이버 오픈 api 검색 기능을 이용해 책을 검색한다.

검색결과는 그리드뷰 리스트로 출력되며, 이미지만 표시된다.

 

파일 구조

 

 

book.dart

class Book {
  final String image;

  Book({required this.image});

  Book.fromJson(Map<String, dynamic> json) : this(image: json["image"]);

  Map<String, dynamic> toJson() => {"image": image};
}

네이버 API가 제공하는 정보 중 이미지만 사용할 예정이기에, class Book 내에 image 속성만 담았다.

 

 

book_repository.dart

import 'dart:convert';

import '/data/model/book.dart';
import 'package:http/http.dart';

class BookRepository {
  Future<List<Book>?> search(String query) async {
    try {
      Client client = Client();
      Response result = await client.get(
        Uri.parse(
          'https://openapi.naver.com/v1/search/book.json?query=$query&display=30',
        ),
        headers: {
          'X-Naver-Client-Id': '미리 발급받은 Id 입력',
          'X-Naver-Client-Secret': '미리 발급받은 Secret 입력',
        },
      );
      final json = jsonDecode(result.body);

      if (result.statusCode == 200) {
        return List.from(json['items']).map((e) => Book.fromJson(e)).toList();
      }
      return null;
    } catch (e) {
      print(e);
      return null;
    }
  }
}

api에 검색어를 포함한 url의 결과를 요청하면서, 필요한 인증 정보를 함께 보낸다.

Get요청 성공 시 응답코드 200이 반환되는데, 이 코드(statusCode)를 사용해 성공여부를 판단한다.

 

참고:

https://developer.mozilla.org/ko/docs/Web/HTTP/Status/200

 

200 OK - HTTP | MDN

HTTP 200 OK 는 요청이 성공했음을 나타내는 성공 응답 상태 코드입니다. 200 응답은 기본적으로 캐시 가능합니다.

developer.mozilla.org

 

Get 요청 성공 시 결과값(json String 타입) jsonDecode로 변환한 값을 사용해 List<Book> 의 형태로 반환한다.

home_page.dart, 

home_view_modle.dart 가 해당 리스트를 사용하여 화면에 결과를 출력할 수 있다.

 

 

RiverPod이란? 

개발자가 상태관리를 편하게 할 수 있도록 도와주는 라이브러리

MVVM 아키텍처 구현과 관리 용이

 

사용법

A. ViewModel 만들기

1. 관리할 데이터를 담을 상태 클래스 만들기

class HomeState{
	int counter;
	HomeState(this.counter);
}

 

2. Notifier를 상속받는 ViewModel 클래스 만들기

class HomeViewModel extends Notifier<HomeState> {
  @override
  HomeState build() {
    return HomeState(1);
  }
  
  void updateState(){
	  state = HomeState(state.counter + 1);
  }
}

 

- 1) Notifier를 상속받으면 상태를 저장하고 업데이트할 수 있는 기능을 갖는다. 

     - ViewModel은 상태가 업데이트될 때 따로 Statefull widget처럼 setState() 하지 않아도 상태가 없데이트되는데, Notifier를 상속받는 것이 그 기능을 하게 해준다.

 

 - 2) Notifier 상속 시 이 ViewModel이 어떤 상태를 관리하는지 제너릭으로 지정해줘야 한다(위 코드 중 Notifier<HomeState>)

 

 - 3) Notifier 클래스의 build 메서드를 재정의해 초기 상태값을 return 한다.

     - Notifier 클래스의 build 메서드는 추상 메서드로 선언되어 있어, Notifier를 상속받은 ViewModel은 반드시 build를 재정의해주어야 한다.

     - '초기값을 리턴한다' 까지만 저장된 규칙이고, 해당 값을 어떻게 설정할 것인지는 사용자가 결정하는 것.

 

3. 뷰모델을 관리 및 공급해 줄 NotifierProvider 객체를 변수에 담는다

final homeViewModelProvider = NotifierProvider<HomeViewModel, HomeState>((){
		return HomeViewModel();
});

 

앞서 만든 HoveViewModel을 Provider로 감싸줌으로써

 - 상태 변경 시 UI 자동 업데이트

 - 필요 없는 경우 정리(dispose) 가 가능해진다. 

 

Provider 생성 시 제너릭으로 관리하는 뷰모델 타입과 관리하는 상태를 명시한다(<HomeViewModel, HomeState>)

 

 

B. 위젯에서 불러오기

Consumer(
	builder: (context, ref, child){
		ref.watch(homeViewModelProvider);
		return Column(children:[
			Text()
		]);
	}
)

 

Consumer : Riverpod 이 정의하는 위젯. Provider의 변경을 감지하고 필요할 때 해당 부분만 리빌드해줌.

 

ref.read(프로바이더) : 1회성. 실행될 때 상태를 읽어 오고 변경은 감지하지 못함

ref.watch(프로바이더) : 상태변경을 감지하여 상태 변경 일어날 때마다 재빌드

 

여기서는 ref.watch를 사용했으므로 HomeViewModel의 HomeState가 변경될 때마다 재빌드가 이루어진다.

 

 

C. 최상위 위젯 ProviderScope로 감싸기

void main() {
  // 이 앱에서 ViewModel을 RiverPod이 관리하게 해주게 해줌
  runApp(const ProviderScope(child: MyApp()));
}

MVVM 이란?

Model + View + ViewModel

 

Model, View, ViewModel로 계층을 나누어서 개발하는 방식

코드의 역할을 명확하게 나누어 사용하는 방식으로, 유지보수와 테스트가 용이함.

 

 

Model

  • 데이터를 관리하는 부분
  • 서버, 데이터베이스 같은 곳에서 데이터를 가져오는 역할
  • 데이터 클래스 자체도 Model로 봄
  • 서버에서 데이터를 가져오는 경우, Repository로 관리

 

View

  • 화면(UI)를 담당
  • 위젯들을 담고 있음
  • 직접 데이터를 다루지 않고, ViewModel로부터 데이터를 받아 사용함
  • ViewModel을 구독함 => ViewModel이 변경되면 바로 알 수 있고, 필요한 데이터를 받을 수 있음 

 

ViewModel

  • ViewModel을 연결하는 중간다리 역할
  • Model(Repository)에서 데이터를 가져와 가공하고, View에 맞는 형태로 변환

 

 

 

MVVM은 플러터 기본 기능으로도 구현 가능하지만, 상태관리 라이브러리를 쓰면 더욱 편하게 구현 가능하다.

상태관리 라이브러리로는 RiverPod이 많이 사용됨

 

Dart 언어를 사용하는 flutter 어플리케이션Java를 사용하는 웹페이지, Python을 사용하는 서버 등을 연결하는 경우, 사용하는 언어가 다르기 때문에 각자가 사용하는 데이터의 형식을 읽어올 수 없음

이 때 사용하는 것이 JSON이다.

 

다트 객체를 JSON 형식으로, 또는 반대로 변환할 때는 직렬화/역직렬화를 거친다.

 

직렬화 : Dart 객체 -> Map -> String
역직렬화 : String -> Map -> Dart 객체

 

왜 Map을 거치는가? 

객체를 바로 String으로 바꾸려면 직접 구현 필요 < 복잡하고 어려움, 번거로움

Dart sdk의 dart:convert 라이브러리의 jsonEncode, jsonDecode를 사용하는 방식이 편리하고, 이 함수들은 Map을 사용하기 때문!

 

jsonEncode 

jsonEncode: Dart 기본 타입(Map, List, int, null, bool 등)JSON String 으로 변환하는 함수

 

문법

String jsonEncode(Object? object)
// object에는 Map, List, int, String 과 같은 다트 기본 자료형만 넣을 수 있음
// 반환 타입 : JSON String

예시

Map<String, dynamic> map = {
	"name": "Harry", 
	"age": 17
};

String jsonData = jsonEncode(map);

print(jsonData);

// {"name":"Harry","age":17}

 

jsonDecode 

jsonDecode: JSON StringDart 구조체 타입(Map, List 등)으로 변환하는 함수

 

문법

dynamic jsonDecode(String source)
// source는 JSON String 타입만 가능
// 반환 타입은 Map<String, dynamic> 또는 List<Dynamic>

예시

String jsonSampleData = """
{
  "name":"Harry",
  "age":17
}
""";

var decodedData = jsonDecode(jsonSampleData);
print(decodedData.runtimeType);		// _Map<String,dynamic>
print(decodedData);		// {name: Harry, age: 17}

 

 

객체에서 jsonEncodejsonDecode 사용하기

Map<String, dynamic> map = {"name": "Harry", "age": 17};

class Human {
  String name;
  int age;

  Human({
  required this.name, 
  required this.age
  });
  
  Human.fromJson(Map<String, dynamic> map)
    : this(name: map['name'], age: map['age']);

  Map<String, dynamic> toJson() {
    return {'name': name, 'age': age};
  }
}

 

1. class Human은 String 타입의 name, int 타입의 age 를 가진다.

2. name, age 는 non - nullable 타입으로, 반드시 값이 필요하기 때문에 required 키워드를 사용해 값을 주고 넘어간다.

3. Human.fromJson 함수로 map에서 'name', 'age'의 키 값을 가져와 Human 객체의 name, age에 설정한다.

4. toJson 함수로 Human 객체의 name, age를 값으로 가지는 Map을 생성한다.

 

 

여기서 fromJson, toJson 함수는 앞서 설명한 jsonEncode, jsonDecode와 비슷한 역할을 하지만, 약간의 차이점이 있다.

 

jsonEncode, jsonDecode : Map, List 같은 기본 자료형에 사용

fromJson, toJson : 클래스(객체)를 Map처럼 변환하거나, Map에서 객체를 만들 때 사용

 

 

또한, 1, 2, 3은 Human을 만들고 적절한 값을 부여하기 위해 필수적인 과정이지만, 4번은 그렇지 않다. 

결과만 놓고 본다면 4번 코드를 아예 삭제해도 class Human은 정상적으로 구현된다.

하지만 프로그램 확장성을 위해 언제든 객체를 Json으로 사용할 준비를 해 두는 것이 실제 개발에서는 필수적이다.

 

 

 

팀 프로젝트를 할 때 자주 사용하는 몇 가지 기능들을 정리했다.

맥 기준이며, vs code 터미널과 깃허브 웹페이지를 이용한다.

 

1. 클론

깃허브 레포지토리, 즉 원격 저장소를 로컬에 옮겨오는 것

처음 협업을 시작하면 클론부터 하고 작업을 시작하게 된다.

 

mac 터미널을 열고 명령어로 프로젝트를 저장할 폴더 열기

cd 폴더 경로

 

원하는 폴더에서 클론 명령어 입력

git clone 저장소url

 

 

저장소 url은 깃허브 레포지토리 메인 페이지에서 우측 Code 를 입력하면 나오는 주소를 복사해서 사용하면 된다.

(브라우저 주소창의 URL을 사용하는 것이 아니다!)

 

 

2. 브랜치 생성

협업 시 각자 브랜치를 생성하고 각 브랜치에서 개인 작업을 진행한다.

 

vs code 터미널 bash에서 브랜치를 생성하는 방식은 다음과 같다.

(이하 모든 명령어들은 vs code 터미널 bash 에 입력한다)

 

 - 1 현재 브랜치 체크

git branch

 

브랜치를 체크한다. 팀 내 브랜치 컨벤션에 따라 올바른 브랜치로 이동해주도록 하자

 

 - 2. 원격에서 브랜치 가져오기

git pull branch 브랜치명

 

로컬로 브랜치를 가져온다. 이미 가져왔다면 생략 가능

 

3. 브랜치 이동

git checkout 브랜치명

 

checkout이라는 명령어를 사용해 원하는 브랜치로 이동한다.

 

다시 한 번 1. 브랜치 체크 명령어를 사용하면 브랜치 이동이 잘 됐는지 확인 가능하다.

 

4. 브랜치 생성 및 이동

git checkout -b 생성할브랜치명

 

브랜치 생성과 이동을 동시에 하는 명령어

역시 1. 브랜치 체크 명령어를 사용하면 브랜치 생성 및 이동이 잘 됐는지 확인 가능

 

 

3. 커밋(commit)

진행상황을 저장하는 것을 말한다.

단순한 일반 파일 저장처럼 현재 내용을 저장해주는 것을 넘어, 어느 시점의 모든 진행상황과 변경내역을 저장해주는 기능이다.

 

2가지 단계로 진행한다.

 

git add .

 

변경사항이 있는 모든 파일을 스테이징하는 명령어.

스테이징이란 커밋을 위한 준비 라고 보면 된다. 어떤 파일을 커밋할지 리스트에 추가하는 과정이고, add 만 하면 커밋이 남지 않는다.

 

git commit -m "커밋 메세지"

 

커밋 명령어

방금 스테이징한 파일들을 커밋하여 커밋 기록을 남긴다.

-m 은 메세지 명령어, "커밋 메세지" 는 말 그대로 커밋 메세지이므로 생략할 수 있다.

 

여기까지 하면 로컬에 커밋이 저장된다.

 

 

4. 푸시(push)

git push origin 브랜치명

 

커밋까지는 내 로컬 저장소에서 일어난 일이었다면, 푸시는 그 내용을 원격 저장소에 보내 주는 과정이다.

브랜치별로 푸시할 수 있다.

 

git push 는 명령어

origin 은 원격 저장소 이름(디폴트가 origin, 프로젝트에 따라 다를 수 있다)

 

푸시까지 하면 커밋 내용을 깃허브 레포지토리에서 확인할 수 있게 된다.

 

 

5. 풀 리퀘스트(pull request)

내 브랜치를 생성하여 코드를 작성하고, 커밋, 푸시하여 원격 저장소에 올렸다. 

이제 내 브랜치에서 진행된 변경 내용을 메인 브랜치에 합쳐서 프로젝트의 일부로 만들어야 한다.

그 전에 팀원들에게 코드 리뷰를 요청하는 것이 풀 리퀘스트다.

이렇게 진행해도 될까요? 문제가 없을까요? 라고 묻는 것이라고 보면 된다.

 

푸시까지 한 뒤 깃허브 레포지토리 좌측 상단에서 Pull request 클릭

 

 

우측에서 New Pull Request 클릭

내 브랜치를 선택하고 나오는 입력창에서 제목, 내용에 변경사항을 팀원들이 알아볼 수 있게 작성하면 된다.

 

팀원들의 확인 후 메인 브랜치에 merge 하면 내 코드가 프로젝트에 적용된다.

 

 

6. 풀 (pull)

협업 중엔 나도 코드를 작업하지만, 팀원들도 동시에 작업을 하고 있다. 

팀원이 작성한 코드가 풀 리퀘스트를 거쳐 메인 브랜치에 merge되었다면 내 로컬에도 해당 변경사항의 적용이 필요하다. 

원격의 변경사항을 로컬로 가져와 주는 것이 풀 이다.

git pull origin 메인브랜치명

 

 

7. 브랜치 이동(팀원 풀리퀘스트 확인할 때)

팀원의 풀리퀘스트를 확인할 때는 코드 자체만 읽어서 가능한 경우도 있지만

대부분의 경우 직접 코드를 실행시켜 보고 그 후에 코드 자체를 확인하는 것이 더욱 정확할 것이다.

git fetch origin

 

원격에서 새 브랜치를 가져와서 로컬에서도 팀원이 커밋한 브랜치에 접근 가능하도록 한다.

 

git checkout 브랜치명

 

브랜치명에는 팀원의 브랜치명을 입력한다.

이렇게 하면 로컬에서 팀원의 브랜치로 이동해 해당 브랜치의 최신 커밋을 확인할 수 있다!

 

단, 브랜치 이동을 하기 전에 내 진행상황을 커밋하여 진행상황이 사라지는 불상사를 방지하자.

 

내 브랜치로 돌아올 때 역시 checkout 명령어를 사용하면 된다.

 

삼항연산자 

조건문의 하나. 플러터 위젯 내에서는 if문의 사용이 제한되어 있기 때문에 이 문법을 많이 사용한다.

 

기본 형식

조건 ? A : B

 

다음 if 문과 같은 역할을 한다.

if (조건) { A }  else { B }

//조건이 참이면 A 실행, 거짓이면 B 실행

 

예시

bool isLast = true;

Padding(
	padding: 
		isLast ? EdgeInsets.zero : EdgeInsets.only(bottom:20)
)
//bool 값에 따라 다른 패딩 값을 사용

 

 

 

null 병합 연산자

기본값 설정에 주로 사용한다.

 

기본 형식

A ?? B

 

다음 if 문과 같은 역할을 한다.

if ( A == null ) { B }

// A가 null이면 B 실행

 

 

예시

String? userName;

Text(userName ?? '이름 없음'),

//userName 값이 없으므로 대신 '이름 없음' 이라는 String 사용

 

 

꼭 플러터 위젯 내부가 아니더라도 간단한 조건문을 한 줄로 표현할 수 있기 때문에 자주 사용한다.

문제상황

 

 

유사한 레이아웃을 가진 페이지 SeatPage(좌), MySeat(우)이 동일한 코드를 반복적으로 사용중

 

레이아웃 코드의 위젯화가 필요한데, 좌석정보 관련 변수가 SeatPage내부에서 선언되어 MySeat로 전달하려면 번거로운 과정을 거쳐야 했다.

 

우선 지역 변수 대신 전역 변수를 사용해 기존 코드를 개선하고, 자주 사용하는 위젯을 분리하기 쉽게 해 주었다.

 

 

기존 방식

SeatPage -> MyTicket -> MySeat로 페이지가 넘어갈 때마다 지역 변수를 전달함

 

좌측

SeatPage => MyTicket 으로 페이지 이동

4개의 지역변수를 전달한다.

 

우측

MyTicket => MySeat 으로 페이지 이동

2개의 지역변수를 전달한다.

 

세 페이지에서 같은 값을 사용함에도 매번 코드를 작성해 줘야 하는 불편함이 있다.

 

 

개선 방식

station_data.dart

seat_data.dart

파일을 만들어 전역변수를 선언했다.

유저 입력에 따라 값을 받을 변수이므로 ?를 사용해 기본값으로 null을 가질 수 있게 한다.

 

 

전역 변수를 사용하면 기존 SeatPage, MySeat에서 반복되는 코드를 외부에 저장하는 것이 편해진다.

 

 

 

기존 코드

MySeat 내부 seat 메서드, textBox 메서드

 

textBox메서드의 경우 레이아웃 부분만 정의하기 때문에 어디서든 사용할 수 있지만, 

seat 메서드는 지역 변수인 selectedRowNum, selectedColStr을 사용하기 때문에 외부로 꺼내 쓰려면 변수 재정의 또는 전달이 필요하다.

 

 

개선 코드

widgets_seats.dart 파일 내 seat 메서드, textBox 메서드 

전역 변수인 selectedRowGlobal, selectedColGlobal을 임포트 한 번으로 쉽게 불러왔다. 

 

 

전역함수 사용 및 리팩토링 전
전역함수 사용 및 리팩토링 후

레이아웃과 기능상 차이는 없지만 전역함수와 외부 메서드 사용으로 짧고 깔끔하게 구현이 가능해졌다.

코드

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: HomePage());
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          fourContainers(),
          fourContainers(),
          fourContainers(),
          fourContainers(),
        ],
      ),
    );
  }

  Padding fourContainers() {
    return Padding(
      padding: EdgeInsets.only(bottom: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
        ],
      ),
    );
  }
}

 

결과

ListView 내에서 fourContainers라는 메서드를 네 번 호출해서 이미지와 같은 레이아웃을 그리고 있다. 

현재는 메서드를 4번 호출하기 때문에 위와 같이 작성해도 문제가 없지만, 호출 횟수가 많아지면 코드의 가독성이 떨어지고 유지보수가 어려워진다.

 

 

https://devlogmj.tistory.com/15

 

[Dart] .generate와 .filled 사용하여 리스트 만들기

.generate 주어진 길이만큼 리스트를 생성하고, 각 요소에 대해 반복적인 패턴의 값을 할당하는 데 사용한다. List.generate() 문법List.generate(length, (index) => expression); length - 생성할 리스트의 길이index

devlogmj.tistory.com

이전에 정리한 .generate 함수를 사용하여 반복되는 메서드 호출을 깔끔하게 구현할 수 있다.

 

 

만약 fourContainers를 20번 반복해야 한다면?

 

기존 코드

몇 번 반복되는지 한 눈에 알아볼 수 없음

 

 

.generate로 개선한 코드

generate 함수 내 인자를 통해 20번 반복된다는 것을 알아볼 수 있다. 

 

또한, generate 함수의 특성상 규칙을 가진 리스트 요소를 간단하게 생성할 수도 있다.

 

예시

인덱스를 사용하여 줄마다 번호를 부여했다.

 

 

사용 코드

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: HomePage());
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          ...List.generate(20, (index) {
            index = index + 1; // index를 1부터 시작하도록 처리
            return fourContainers(index);
          }),
        ],
      ),
    );
  }

  Padding fourContainers(index) {
    return Padding(
      padding: EdgeInsets.only(bottom: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          SizedBox(width: 50, height: 50, child: Text('$index')),

          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
        ],
      ),
    );
  }
}

 

전체 코드

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: HomePage());
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('HOME')),
      body: Center(


        ///여기부터 Container 테두리 예시코드 
        ///
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border.all(color: Colors.black, width: 1),
              ),
            ), // all: 모든 테두리

            SizedBox(height: 50),

            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border(
                  left: BorderSide(color: Colors.black, width: 1),
                  // right: BorderSide(color: Colors.black, width: 1),
                  top: BorderSide(color: Colors.black, width: 1),
                  bottom: BorderSide(color: Colors.black, width: 1),
                ),
              ),
            ), // BorderSide: 지정 테두리 -- 좌, 우, 상, 하 지정하여 각각 그리기

            SizedBox(height: 50),

            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border.symmetric(
                  horizontal: BorderSide(color: Colors.black, width: 1),
                ),
              ),
            ), // symmetric: 상하 또는 좌우 테두리 -- 좌우 테두리

            SizedBox(height: 50),

            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border.symmetric(
                  vertical: BorderSide(color: Colors.black, width: 1),
                ),
              ),
            ), // symmetric: 상하 또는 좌우 테두리 -- 상하 테두리
          ],
        ),
      ),
    );
  }
}

 

결과물

 

 

 

전체 테두리

Border.all(color: {색상} , width: {테두리 두께} )

 

 

지정 테두리

Border(
	left: BorderSide(color: {색상} , width: {테두리 두께} ),   // 왼쪽 테두리
    right: BorderSide(color: {색상} , width: {테두리 두께} ),  // 오른쪽 테두리
    top: BorderSide(color: {색상} , width: {테두리 두께} ),    // 상단 테두리
    bottom: BorderSide(color: {색상} , width: {테두리 두께} ), // 하단 테두리
)​

 

상하 / 좌우 테두리

Border.symmetric(
	horizontal: BorderSide(color: Colors.black, width: 1),
),
// horizontal 이면 좌우, vertical 이면 상하

+ Recent posts