Flutter Youtube 화면 개발


2주차 스터디는 Youtube Api를 활용해서 화면 구성을 공부해보는 시간을 가져봅니다.
이번시간에 배우는 부분은 아래와 같습니다.
우선 프로젝트를 기본적으로 구성을 해봅니다.
main.dart
import 'package:flutter/material.dart';
import 'package:youtube_clone_app/src/app.dart';
void main() => runApp(App());
app.dart
import 'package:flutter/material.dart';
import 'package:youtube_clone_app/src/home.dart';
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
primarySwatch: Colors.red
),
home: Home(),
);
}
}
Home Widget
Home 위젯은 탭이 포함된 메인 을 말합니다.
탭은 총 3개로 구성될 예정입니다. 매주 공부할 내용을 기반으로 하나씩 채워나가면서 만들어 볼 예정입니다.
Youtube ( 2주차 )
Chat ( 3주차 )
Nearby ( 4주차 )
Home
위젯은 탭이 바뀌면 setState
를 호출해서 변해야 해서 statefulwidget
으로 만듭니다.
class Home extends StatefulWidget {
@override
State<StatefulWidget> createState() => HomeState();
}
class HomeState extends State<Home>{
...
}
내용은 크게 appbar
, body
, bottomNavigationBar
가 포함되어 있습니다.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildMatchAppbar(context),
body: _tabs[_tabIndex],
bottomNavigationBar: _buildBottomNavigationBar(context),
);
}
AppBar를 커스텀 해서 만들어줍니다.
Widget _buildMatchAppbar(context) {
return AppBar(
title: Text(
"Flutter Study App",
style: TextStyle(color: AppBarColor),
),
backgroundColor: AppBackgroundColor,
);
}
3개의 화면 위젯을 미리 만들어서 리스트로 호출하고 있습니다.
var _tabIndex = 0;
final List<Widget> _tabs = [YoutubeScreen(), ChatScreen(), NearByScreen()];
BottomNavigationBar 라고 flutter 에서 미리 만들어서 제공해주는 탭바 비슷한게 있습니다. Scaffold
기본 위젯에 포함되어 있어서 생성 후 바로 사용이 가능합니다.
_buildBottomNavigationBar(context) => Theme(
data: Theme.of(context).copyWith(
canvasColor: AppBackgroundColor,
primaryColor: BottomNaviItemSelectedColor,
iconTheme: Theme.of(context).iconTheme.copyWith(color: BottomNaviItemColor),
textTheme: Theme.of(context).textTheme.copyWith(caption: TextStyle(
color: BottomNaviItemColor
)),
),
child: BottomNavigationBar(
items: <BottomNavigationBarItem>[
_bottomNavigationBarItem(Icons.videocam, "youtube"),
_bottomNavigationBarItem(Icons.chat, "chat"),
_bottomNavigationBarItem(Icons.map, "nearby")
],
type: BottomNavigationBarType.fixed,
currentIndex: _tabIndex,
onTap: (int index) {
setState(() {
_tabIndex = index;
});
},
));
BottomNavigationBarItem _bottomNavigationBarItem(IconData icon, String text) {
return new BottomNavigationBarItem(icon: Icon(icon), title: Text(text));
}
탭 이벤트가 발생시 onTap 함수를 통해서 setState 호출해서 body 를 변경하고 있습니다.
onTap: (int index) {
setState(() {
_tabIndex = index;
});
Home.dart
전체 소스는 다음과 같습니다.
import 'package:flutter/material.dart';
import 'package:youtube_clone_app/src/tabs/youtubeScreen.dart';
import 'package:youtube_clone_app/src/commons/colors.dart';
import 'package:youtube_clone_app/src/tabs/NearByScreen.dart';
import 'package:youtube_clone_app/src/tabs/ChatScreen.dart';
class Home extends StatefulWidget {
@override
State<StatefulWidget> createState() => HomeState();
}
class HomeState extends State<Home>{
var _tabIndex = 0;
final List<Widget> _tabs = [YoutubeScreen(), ChatScreen(), NearByScreen()];
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildMatchAppbar(context),
body: _tabs[_tabIndex],
bottomNavigationBar: _buildBottomNavigationBar(context),
);
}
Widget _buildMatchAppbar(context) {
return AppBar(
title: Text(
"Flutter Study App",
style: TextStyle(color: AppBarColor),
),
backgroundColor: AppBackgroundColor,
);
}
_buildBottomNavigationBar(context) => Theme(
data: Theme.of(context).copyWith(
canvasColor: AppBackgroundColor,
primaryColor: BottomNaviItemSelectedColor,
iconTheme: Theme.of(context).iconTheme.copyWith(color: BottomNaviItemColor),
textTheme: Theme.of(context).textTheme.copyWith(caption: TextStyle(
color: BottomNaviItemColor
)),
),
child: BottomNavigationBar(
items: <BottomNavigationBarItem>[
_bottomNavigationBarItem(Icons.videocam, "youtube"),
_bottomNavigationBarItem(Icons.chat, "chat"),
_bottomNavigationBarItem(Icons.map, "nearby")
],
type: BottomNavigationBarType.fixed,
currentIndex: _tabIndex,
onTap: (int index) {
setState(() {
_tabIndex = index;
});
},
));
BottomNavigationBarItem _bottomNavigationBarItem(IconData icon, String text) {
return new BottomNavigationBarItem(icon: Icon(icon), title: Text(text));
}
}
Youtube Widget
youtube
위젯도 동적으로 리스트를 가져와야 해서 Stateful
위젯으로 구성합니다.
class YoutubeScreen extends StatefulWidget {
@override
_YoutubeState createState() => _YoutubeState();
}
class _YoutubeState extends State<YoutubeScreen> with AutomaticKeepAliveClientMixin<YoutubeScreen>{
@override
bool get wantKeepAlive => true;
}
리스트 구성
Build
함수내에 ListBuilder()
를 통해서 리스트를 구성해봅니다.
...
return _createListBuilder();
...
_createListBuilder() => FutureBuilder(
future: _getVideos(),
builder: (BuildContext context, AsyncSnapshot snapshot){
switch(snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return Center(
child: CircularProgressIndicator(),
);
default :
var list = snapshot.data;
return ListView.builder(
itemCount: list.length,
itemBuilder: (BuildContext context, int index) => _buildListItem(context, list[index])
);
}
},
);
FutureBuilder를 통해서 비동기로 youtube api를 조회해서 결과값이 리턴되는 경우 snapshot
으로 콜백이 다시 들어옵니다.
_getVideos()
함수를 통해서 api 를 호출합니다.
빌더 함수를 통해서 상태제어를 할수 있습니다.
기본적으로 처음 호출시 connectionState
는 waiting
상태가 됩니다. 그리고 결과가 리턴되면 done
으로 바뀌어서 다시 들어오게 됩니다.
데이터 가져오는 동안 중간에 Progress
를 올려서 ProgressBar
올려서 볼수 있습니다.
snapshot.data
는 추후에 나올 함수 list 로 리턴받게 됩니다.
ListView.builder
를 통해서 리스트를 만들고 있습니다.
_buildListItem
를 통해서 리스트내 각각의 리스트 아이템을 만들어주고 있습니다.
리스트 아이템 구성
_buildListItem
을 통해서 구성이 됩니다. 전체적인 트리구조는 다음과 같습니다.

_buildListItem(context, video) {
return InkWell(
onTap: () {
Scaffold.of(context).showSnackBar(SnackBar(content: Text("clicked : $video")));
},
child: Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: BorderColor)),
),
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Flex(
direction: Axis.vertical,
children: <Widget>[
_buildItemVideoThumbnail(video),
_myTubeVideoContent(video)
],
)
),
),
);
Thumbnail 이미지 구성
썸네일은 FadeIn 되는 구조를 가집니다.
_buildItemVideoThumbnail(VideoData video) => AspectRatio(
aspectRatio: 1.8,
child: FadeInImage.memoryNetwork(
placeholder: kTransparentImage,
image: video.getThumbnailUrl,
fit: BoxFit.cover
)
);
VideoData 라는 데이터 클래스를 통해서 썸네일 이미지를 설정해주고 있습니다.
AspectRatio
을 통해서 종횡비 비율을 조절하고 있습니다.
kTransparentImage
placeHolder 를 통해서 fadeIn 되는 효과를 만들수 있습니다.
import 'dart:typed_data';
final Uint8List kTransparentImage = new Uint8List.fromList(<int>[
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
]);
하단 화면 구성하기
각각의 리스트 아이템에서 하단 화면을 구성해봅니다.
_myTubeVideoContent(VideoData video) => Container(
alignment: Alignment.topCenter,
margin: EdgeInsets.only(top: 10.0),
child: Row(
children: <Widget>[
Container(
margin: EdgeInsets.only(right: 10.0),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: BorderColor,
image: DecorationImage(
image: NetworkImage(video.getChannelData.getThumbnailUrl),
fit: BoxFit.contain,
)
),
width: 32.0,
height: 32.0,
),
Flexible(
child: Column(
children: <Widget>[
Container(
alignment: Alignment.centerLeft,
child: Text(
video.getTitle,
maxLines: 2,
style: TextStyle(
fontWeight: FontWeight.w400,
fontSize: 16.0,
color: TextColor
),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left),
),
Container(
alignment: Alignment.centerLeft,
child: Text(
"${video.getChannelData.getName}",
maxLines: 2,
textAlign: TextAlign.left,
style: TextStyle(color: TextColor,),
),
),
],
mainAxisAlignment: MainAxisAlignment.start,
),
flex: 1,
),
InkWell(
child: Container(child: Icon(Icons.more_vert, size: 20.0, color: BorderColor),),
onTap: _modalBottomSheet,
borderRadius: BorderRadius.circular(20.0)
)
],
),
);

하단은 프로필 써클 이미지와 기타 설명으로 구성되어 있습니다. 세로로 구성되어 지기 때문에 Row 로 설정합니다.
써클 이미지 구성은 BoxDecoration
위젯 안에 NetworkImage 를 가져옵니다.
image: DecorationImage(
image: NetworkImage(video.getChannelData.getThumbnailUrl),
fit: BoxFit.contain,
)
3,4번은 설명글을 구성합니다. Column
을 통해서 가로로 설정합니다.
마지막으로 InkWell
을 통해 Ripple
효과를 줘서 더보기 버튼을 구성해봅니다.
더보기 클릭
_modalBottomSheet
바텀시트를 올려서 메뉴를 볼수 있습니다.
_modalBottomSheet() => showModalBottomSheet(
context: context,
builder: (builder) => Container(
color: AppBackgroundColor,
child: new Column(
children: <Widget>[
_bottomSheetListTile(Icons.not_interested, "관심 없음", () => debugPrint("관심 없음")),
_bottomSheetListTile(Icons.access_time, "나중에 볼 동영상에 추가", () => debugPrint("나중에 볼 동영상에 추가")),
_bottomSheetListTile(Icons.playlist_add, "재생목록에 추가", () => debugPrint("재생목록에 추가")),
_bottomSheetListTile(Icons.share, "공유", () => debugPrint("공유")),
_bottomSheetListTile(Icons.flag, "신고", () => debugPrint("신고")),
Container(
decoration: new BoxDecoration(
border: new Border(top: new BorderSide(color: BorderColor))
),
child: _bottomSheetListTile(Icons.close, "취소", () => Navigator.pop(context)),
)
],
),
)
);
_bottomSheetListTile(IconData icon, String text, Function onTap) =>
ListTile(
leading: Icon(icon, color: TextColor),
title: Text(text, style: TextStyle(color: TextColor),),
onTap: onTap
);
마지막으로 비디오를 가져오는 부분을 추가해서 데이터를 갱신할 수 있도록 해줍니다.
Future<List<VideoData>> _getVideos() async {
List<VideoData> videoDataList = new List<VideoData>();
String dataURL = "https://www.googleapis.com/youtube/v3/videos?chart=mostpopular®ionCode=KR"
"&maxResults=20&key=$youtubeApiKey&part=snippet,contentDetails,statistics,status";
http.Response response = await http.get(dataURL);
dynamic resBody = json.decode(response.body);
List videosResData = resBody["items"];
videosResData.forEach((item) => videoDataList.add(new VideoData(item)));
for (var videoData in videoDataList) {
String channelDataURL = "https://www.googleapis.com/youtube/v3/channels?key=$youtubeApiKey&part=snippet&id=${videoData.getOwnerChannelId}";
http.Response channelResponse = await http.get(channelDataURL);
dynamic channelResBody = json.decode(channelResponse.body);
videoData.channelDataFromJson = channelResBody["items"][0];
}
return videoDataList;
}
전체 리스트를 가져온다음 각각 채널 정보를 다시 호출하기 때문에 비효율적으로 보이지만 스터디 목적이기 때문에 따로 리팩토링은 하지 않겠습니다.
JSON Decoding
미리 VideoData 와 ChannelData 클래스를 만들어서 Json decoding 해줄수 있습니다.
다른 방법도 있지만 여기선 기본적인 방법으로 설명합니다.
VideoDart.dart
import './ChannelData.dart';
class VideoData {
String ownerChannelId;
String thumbnailUrl;
String title;
String viewCount;
String publishDate;
ChannelData channelData;
VideoData(Map videoJsonData) {
this.ownerChannelId = videoJsonData["snippet"]["channelId"];
this.thumbnailUrl = videoJsonData["snippet"]["thumbnails"]["high"]["url"];
this.title = videoJsonData["snippet"]["title"];
this.viewCount = videoJsonData["statistics"]["viewCount"];
this.publishDate = videoJsonData["snippet"]["publishedAt"];
}
VideoData.includeChannelData(Map videoJsonData, Map channelJsonData) {
VideoData(videoJsonData);
this.channelData = ChannelData(channelJsonData);
}
set channelDataFromJson(Map channelJsonData) {
this.channelData = new ChannelData(channelJsonData);
}
get getOwnerChannelId => this.ownerChannelId;
get getThumbnailUrl => this.thumbnailUrl;
get getTitle => this.title;
get getViewCount => this.viewCount;
get getPublishedDate => this.publishDate;
get getChannelData => this.channelData;
}
ChannelData.dart
class ChannelData {
String name;
String thumbnailUrl;
ChannelData(Map channelJsonData) {
this.name = channelJsonData["snippet"]["title"];
this.thumbnailUrl = channelJsonData["snippet"]["thumbnails"]["default"]["url"];
}
get getName => this.name;
get getThumbnailUrl => this.thumbnailUrl;
}
전체적인 화면 트리는 다음과 같습니다.

여기까지 2주차 유튜브 리스트 개발이었습니다.
개선해야 될 내용
탭이 변경시 계속 갱신되는 문제
좀 더 효율적으로 API 를 호출하는 부분
그럼 다음 주차에는 카카오톡 같은 메신징 화면을 Firebase
연동해서 개발해보도록 하겠습니다.
참석해주셔서 감사합니다.