Flutter UI - 타다(TADA) 앱

kkensu
조현철의 개발로그
18 min readNov 30, 2022

안녕하세요! 오랜만의 포스팅 이네요. 지난 번 카카오 지도 글을 마지막으로 새로운 곳으로 이직을 하게 되어 포스팅을 오랫동안 못하고 있었습니다. 이대로 있다가는 올해가 다 지나도 포스팅을 못할 수도 있을 것 같아 다시 마음을 다잡고 포스팅 할 주제를 정해보았습니다.

이번에는 타다 앱을 분석하며 따라해 보려고 합니다. 지난 포스팅들에서 지도에 그리기 API 를 이용하여 구현 했던 것을 이용하여 앱을 만들어 보려고 타다앱을 택하게 되었습니다.

네이버지도 API 키 발급 및 프로젝트 설정에 대한 이전 글을 못 보신 분들은 아래 링크를 통해 보시면 좋을 것 같습니다.

그럼 시작해 보겠습니다!

1. 영역구분

영역은 크게 4개로 나누었습니다. 처음에는 Flutter에서 기본으로 제공하는 BottomSheet 또는 SlidingUpPanel 라이브러리를 이용하여 구현하려고 생각했습니다. 그런데 타다앱을 사용해 보면 ④번 영역이 변경됨에 따라 ①번 지도가 변경되는 형태로 되어 있습니다. 아래의 이미지를 보시면 이해하기 쉬우실 겁니다.

어떻게 구현할 수 있을까 고민하다가 최대한 비슷(?) 하게 구현하는걸 목표로 코드를 작성해 보았습니다. CustomScrollView의 slivers 를 이용하여 SliverAppBar의 flexibleSpace 속성을 이용하는 것으로 방향을 잡고 진행하였습니다.

최종 구현의 형태가 위의 타다앱의 형태와 완전히 동일하지는 않기 때문에 이렇게도 구현할 수 있구나 정도로 생각하고 보시면 될 것 같습니다.

2. ①번/③번 분리

두 영역은 Column 으로 선언하여 하단영역에 따라 지도 영역이 Expanded 되도록 하였습니다.

Column(
children: [
Expanded(
child: const NaverMap(), // ①번
),

Type3(), // ③번 영역
]
)

그 후 ①번 지도 영역은 Sliver를 사용하기 위해 CustomScrollView 를 이용했습니다. SliverAppBar, SliverList를 사용하였고, SliverAppBar에 지도와 ②번 영역을 만들어 넣었습니다.

Column(
children: [
Expanded(
child: CustomScrollView(
slivers: [
SliverAppBar(
flexibleSpace: Stack(
children: [
Column(
children: [
Expanded(
child: NaverMap(),
),
const SizedBox(height: 16),
],
),
const Type2(),
Align(
alignment: Alignment.bottomCenter,
child: Container(
width: double.infinity,
height: 24,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
alignment: Alignment.center,
child: Container(
width: 60,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
),
),
), // ④번영역의 위쪽 bar를 그려주기 위해 구현
],
),
backgroundColor: Colors.white,
expandedHeight: height,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Column(
children: const [
Type4(),
],
);
},
childCount: 1,
),
)
],
),

Type3(),
]
)

위 코드 중 살펴 보아야 할 것은 SliverAppBar → flexibleSpace 에 선언 했다는 것입니다. expandedHeight는 MediaQuery.of(context).size.height - 400 값을 사용했습니다. 400은 ④번 영역을 생각했습니다. 아래에서 다시 한 번 더 설명하는 것으로 하겠습니다.

Align 영역은 원래 ④번 영역의 위쪽 Bar를 그려주기 위함인데 만약 ④번 영역 내부에 들어가게 되면 Bar 부분도 같이 스크롤 될 수 있기 때문에 flexibleSpace 에 두게되었습니다.

③번 영역은 하단 버튼 영역과 결제수단, 쿠폰/크레딧 관련 영역을 구분해서 아래와 같이 구성했습니다. 이 때 결제수단, 쿠폰/크레딧 영역의 형태가 비슷하여 subItem() 이라는 함수를 만들어서 사용했습니다.

class Type3 extends StatefulWidget {
const Type3({Key? key}) : super(key: key);

@override
State<Type3> createState() => _Type3State();
}

class _Type3State extends State<Type3> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: subItem(Icons.card_membership, '결제수단', '카카오뱅크 **8354 개인')),
Container(
width: 1,
height: 24,
color: Colors.grey.withOpacity(0.2),
),
Expanded(child: subItem(Icons.airplane_ticket_rounded, '쿠폰 / 크레딧', '할인 적용됨')),
],
),

/// 버튼
Ink(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF1B264A),
borderRadius: BorderRadius.circular(4),
),
child: InkWell(
onTap: () {
// 버튼 클릭 이벤트
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Text(
'넥스트 선택',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
)
],
),
);
}

// 결제수단 , 쿠폰/크레딧 구현을 위한 공통 위젯
Widget subItem(IconData icon, String title, String content) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 16),
const SizedBox(width: 4),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_right_outlined, size: 14)
],
),
const SizedBox(height: 8),
Text(content)
],
),
);
}
}

3. ②번 영역

이 영역은 지도 위에 떠 있는 UI 입니다. 따라서 지도를 Stack 으로 감싸고 지도 위에 해당 UI를 구현하였습니다.

class Type2 extends StatefulWidget {
const Type2({Key? key}) : super(key: key);

@override
State<Type2> createState() => _Type2State();
}

class _Type2State extends State<Type2> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Container(
margin: const EdgeInsets.all(16),
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
onTap: () {
// 뒤로가기 버튼 누르면 pop
// 샘플 앱에서는 쌓여있는 위젯이 없어 아래와 같이 코드를 넣으면
// 검정색 화면만 나오게 됨
Navigator.of(context).pop();
},
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(Icons.arrow_back_ios, size: 18),
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Expanded(child: Text('강남역[2호선]', textAlign: TextAlign.center)),
Icon(Icons.arrow_right_alt),
Expanded(child: Text('남산서울타워', textAlign: TextAlign.center)),
],
),
)
],
),
),
),
);
}
}

크게 어려운 부분은 없습니다. 다만 주의해서 봐야 할 부분은 Expanded 위젯 부분입니다. Row나 Column을 사용할 때 고정된 영역이 어디인지 결정하고, 그 나머지 영역은 Expanded로 설정하여 화면 사이즈별 대응을 해주어야 합니다. 만약 이것을 해주지 않으면 일명 “공사중” 화면을 보게 될 것입니다.

4. ④번 영역

이 영역은 스크롤 되는 영역입니다. 위에서 잠깐 설명했지만 최대 높이를 400설정 하였고 이 부분이 넘어가면 스크롤이 되는 형태입니다. 또 스크롤이 되면서 flexibleSpace 가 확대/축소 되고 타다와 비슷한 UI 가 나오게 됩니다.

class Type4 extends StatefulWidget {
const Type4({Key? key}) : super(key: key);

@override
State<Type4> createState() => _Type4State();
}

class _Type4State extends State<Type4> {
@override
Widget build(BuildContext context) {
return Column(
children: [
subItem(),
subItem(),
subItem(),
subItem(),
subItem(),
],
);
}

Widget subItem() {
return InkWell(
onTap: () {
debugPrint('***** [JHC_DEBUG] 선택');
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 50,
height: 50,
decoration: const BoxDecoration(
color: Colors.purple,
shape: BoxShape.circle,
),
), // 좌측 차량 이미지
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Text('넥스트', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(width: 4),
Icon(Icons.person, size: 14),
SizedBox(width: 4),
Text('5', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(width: 4),
Icon(Icons.info_outline, size: 14),
],
),
const SizedBox(height: 6),
Text(
'대형 RV의 쾌적한 이동',
style: TextStyle(color: Colors.grey.withOpacity(0.8)),
),
],
),
),
), // 중간 차량 타입
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
children: [
Icon(Icons.arrow_upward, size: 12, color: Colors.grey.withOpacity(0.8)),
Text('2.0배', style: TextStyle(fontSize: 12, color: Colors.grey.withOpacity(0.8))),
],
),
const SizedBox(height: 2),
const Text('예상 17,200원', style: TextStyle(fontSize: 16)),
const SizedBox(height: 2),
Text(
'예상 23,200원',
style: TextStyle(
decoration: TextDecoration.lineThrough,
fontSize: 14,
color: Colors.grey.withOpacity(0.8),
),
),
],
) // 오른쪽의 요금
],
),
),
);
}
}

이렇게 하여 영역은 전체적으로 다 분리하여 타다 UI 와 비슷하게 구성 되었을 것입니다. 그럴싸 하게 된 듯 보이나요?

이제 남은 것은 지도와 관련된 부분들 입니다.

5. 네이버 지도

네이버 지도 키 생성 및 관련 설정은 맨 처음 설명한 대로 이전 포스팅을 보시면 쉽게 설정하실 수 있습니다.

5–1. Gesture

지도도 화면에 보이고, 나머지 UI 들도 다 구성되었는데 지도를 움직이려고 하면 지도는 안움직이고 CustomScrollView 가 gesture 우선권이 있다보니 위 아래로 스크롤이 되는것을 보실 수 있습니다.

이걸 해결하기 위해서는 NaverMap 의 속성 중 forceGesture 를 true 로 설정하면 gesture 우선권을 가지게 되어 지도가 움직일 수 있게 됩니다.

5–2. 마커

출발, 도착 마커를 찍어 주어야 합니다. 마커는 기본 마커로 대신하려고 합니다. NaverMap 위젯에 markers 속성에 추가할 마커를 세팅하면 됩니다.

// 마커 리스트 생성
List<Marker> markers = [];

// 출발/도착 마커 객체 생성
var src = Marker(markerId: UniqueKey().toString(), position: LatLng(37.49795, 127.027637));
var dest = Marker(markerId: UniqueKey().toString(), position: LatLng(37.551261444442886, 126.98821468278766));

// 마커 리스트에 추가
markers.add(src);
markers.add(dest);

// 네이버 지도 위젯에 markers 추가
NaverMap(
markers: markers,
)

5–3. PathOverlay (Polyline)

구글에서는 Polyline이라고 부르고 네이버는 PathOverlay 라고 부릅니다. 마커와 마찬가지로 NaverMap 위젯에 pathOverlays 라는 속성에 추가할 pathOverlay를 세팅하면 됩니다.

// PathOverlay 리스트 생성. 단, 중복을 제거하기 위해 Set으로 설정
Set<PathOverlay> pathOverlays = {};

// Path를 그릴 좌표 리스트 변환
List<LatLng> coords = <LatLng>[];
var list = route["list"] as List;
for (Map<String, double> item in list) {
coords.add(LatLng(item["latitude"] as double, item["longitude"] as double));
}

// PathOverlay 객체 생성
PathOverlay pathOverlay = PathOverlay(
PathOverlayId(UniqueKey().toString()),
coords,
color: Colors.blue,
width: 5,
);

pathOverlays.add(pathOverlay);

// 네이버 지도 위젯에 pathOverlays 추가
NaverMap(
pathOverlays: pathOverlays,
)

6. 결과

영역분리하여 위젯 구현 및 지도까지 모두 구현하면 위와 같은 결과를 확인 할 수 있습니다~! 👏🏻

7. 전체소스

위 샘플을 github에 올려두었습니다.

프로젝트 받으셔서 실행시켜 보시면 어떻게 실행되는지 볼 수 있습니다. API KEY 관련 부분을 위 내용 참조 하셔서 작성해 주셔야 합니다.

8. 정리

네이버 지도를 이용하여 타다UI 와 비슷하게 구현해 보았습니다. 최대한 비슷하게 해보려고 했는데 생각보다 마음에 들지는 않는 것 같습니다. flexibleSpace 에 넣으니 ④번 영역이 스크롤 될 때 지도 부분이 일그러지듯 하는 모습이 보여서 더 그런 듯 합니다. 하지만 flexibleSpace 에 지도 위젯을 넣어보기도 하면서 새로운 시도(?)를 해본 것 같아 재미있었던 것 같습니다!

혹시 궁금한 점이 있다면 말씀 해 주세요! 제가 잘못 한 점도 있다면 피드백 해주세요! 저에게 큰 도움이 됩니다~!

감사합니다! 🙏🏻

--

--