본문 바로가기
Flutter

[Flutter] PROJECT(1) - YOUTUBE MUSIC 홈 화면 구현

by 아마도개발자 2023. 12. 17.

 

Source code : https://github.com/FreeBono/youtube-music

 

 

플러터로 YOUTUBE MUSIC 홈 화면을 구현해 보았다. 완전히 일치하게 만들지는 않았고, 기본 UI틀을 지키면서 비슷한 느낌을 낼 수 있도록 하였다. 

참고 이미지 (출처: Mobbin)

 

Youtube Music의 홈화면은 ListView의 사용이 많을 것이라 생각되었다. 그래서 ListView를 보다 쉽고 효과적이게 구현할 수 있는 SliverList를 사용하는 것을 계획했다.

appBar부터 bottomNavigationBar까지 어떤 식으로 코드를 작성했는지 살펴보겠다.

 

appBar 구현

 

appBar 구현

 

홈 화면 최상단에 유튜브 뮤직로고와 함께 서치 아이콘, 프로필 아이콘이 있는 appBar를 확인할 수 있다. 그리고 그 아래 음악 장르를 나누는 카테고리를 확인할 수 있는데, 실제 유튜브 뮤직앱을 보면 앱을 스크롤하여 내렸을 때, 최상단의 앱바는 사라지고 카테고리가 pin되는 것을 확인할 수 있다. 나는 이것을 구현하기 위해 최상단 로고 부분과 카테고리 부분 둘 다 SliverAppBar로 구현을 하였다. 처음에는 하나의 SliverAppBar에서 ExpandedHeight속성을 통해서 구현하려 했지만 내가 원하는 형태가 제대로 나오지 않아 AppBar를 두개 사용하였다.

 

import 'package:flutter/material.dart';

class CustomAppBar extends StatelessWidget {
  const CustomAppBar();

  @override
  Widget build(BuildContext context) {
    return SliverAppBar(
      pinned: false,
      backgroundColor: Colors.transparent,
      title: Row(
        children: [
          Container(
            // color: Colors.red,
            width: 120,
            height: 30,
            child: Image.asset('assets/youtube-icon.png', fit: BoxFit.cover),
          ),
          Spacer(),
          Padding(
            padding: const EdgeInsets.only(right: 15),
            child: Icon(
              Icons.search,
              size: 30,
            ),
          ),
          Icon(
            Icons.account_circle,
            size: 30,
          )
        ],
      ),
    );
  }
}

 

  • pinned는 false로 하여 앱이 스크롤되어 내려갔을 때 사라지도록 하였다.
import 'package:flutter/material.dart';
import 'package:youtube_music/constants.dart';
import 'package:youtube_music/model/home.dart';

class HomeCategory extends StatelessWidget {
  final bool isSliverAppBarVisible;
  const HomeCategory({Key? key, required this.isSliverAppBarVisible})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    print(isSliverAppBarVisible);
    return SliverAppBar(
        pinned: true,
        toolbarHeight: 60,
        backgroundColor:
            !isSliverAppBarVisible ? Colors.transparent : Colors.black,
        elevation: 0,
        title: SizedBox(
          height: 40,
          child: ListView.builder(
            scrollDirection: Axis.horizontal,
            itemCount: category.length,
            itemBuilder: (context, index) {
              return Padding(
                padding: const EdgeInsets.only(right: 15.0),
                child: Container(
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(10),
                    color: Colors.grey.withOpacity(0.3),
                  ),
                  // height: 30,
                  child: Padding(
                    padding: homePadding,
                    child: Center(
                        child: Text(
                      category[index],
                      style: TextStyle(fontWeight: FontWeight.bold),
                    )),
                  ),
                ),
              );
            },
          ),
        ));
  }
}
  • pinned를 true로 하여 앱이 스크롤되어 내려가도, 앱바를 화면 상단에 고정할 수 있도록 하였다.
  • isSliverAppBarVisible은 부모 위젯에서 전달받은 값으로, 스크롤되어 앱바가 보이지 않았을 때, 배경색을 바꿔주기 위해 사용하였다.
import 'package:flutter/material.dart';
import 'package:youtube_music/screens/common/custom_appbar.dart';
import 'package:youtube_music/screens/common/custom_bottom_navigationbar.dart';
import 'package:youtube_music/screens/home/components/home_category.dart';
import 'package:youtube_music/screens/home/components/home_explore.dart';
import 'package:youtube_music/screens/home/components/home_playlist.dart';
import 'package:youtube_music/screens/home/components/home_recommand_list.dart';
import 'package:youtube_music/screens/home/components/home_singer_theme.dart';

class HomeScreen extends StatefulWidget {
  HomeScreen();

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final ScrollController _scrollController = ScrollController();
  bool _isSliverAppBarVisible = true;

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    // Calculate the position of SliverAppBar based on scroll offset
    setState(() {
      _isSliverAppBarVisible = _scrollController.hasClients &&
          _scrollController.offset >
              kToolbarHeight; // Adjust this value as needed
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        extendBodyBehindAppBar: true,
        body: SafeArea(
          child: Container(
            decoration: BackgroundBox(),
            child: CustomScrollView(
              controller: _scrollController,
              slivers: [
                CustomAppBar(),
                HomeCategory(isSliverAppBarVisible: _isSliverAppBarVisible),
                SliverList(
                  delegate: SliverChildListDelegate([
                    HomePlaylist(),
                    HomeSingerTheme(),
                    HomeRecommendList(),
                    HomeExplore(),
                  ]),
                ),
              ],
            ),
          ),
        ),
        bottomNavigationBar: CustomBottomNavigationBar());
  }

  BoxDecoration BackgroundBox() {
    return !_isSliverAppBarVisible
        ? BoxDecoration(
            gradient: LinearGradient(
                begin: FractionalOffset(0.3, -0.9), //,
                end: FractionalOffset(0.4, 0.32), //Alignment.topCenter,
                colors: [
                  Colors.orange,
                  Colors.black,
                  // Colors.orange,
                ],
                // stops: [0.1, 0.5, 0.5],
                tileMode: TileMode.clamp),
          )
        : BoxDecoration(color: Colors.black);
  }
}

 

  • 리스트뷰의 scrollDirection을 Axis.horizontal로 하여 가로 방향으로 스크롤이 가능하도록 하였다.

 

 

playList 구현

 

 

 

PlayList 부분은 하나의 페이지 단위로 화면에 보여주기 때문에 ListView가 아닌 PageView를 사용하였다.  

 

import 'package:flutter/material.dart';
import 'package:youtube_music/constants.dart';
import 'package:youtube_music/model/home.dart';
import 'package:youtube_music/screens/home/components/home_playlist_card.dart';

class HomePlaylistBody extends StatelessWidget {
  const HomePlaylistBody({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
        padding: homePadding,
        child: SizedBox(
          height: 300,
          child: PageView.builder(
            scrollDirection: Axis.horizontal,
            itemCount: songs.length % 4 == 0
                ? songs.length ~/ 4
                : songs.length ~/ 4 + 1,
            itemBuilder: (context, index) {
              List<Song> ls = songs.sublist(index * 4,
                  index * 4 + 4 <= songs.length ? index * 4 + 4 : songs.length);
              return Column(
                children: [
                  for (int i = 0; i < ls.length; i++) ...[
                    HomePlayListCard(
                        title: ls[i].title,
                        singer: ls[i].singer,
                        imageUrl: ls[i].imgUrl),
                    SizedBox(
                      height: 15,
                    )
                  ]
                ],
              );
            },
          ),
        ));
  }
}

 

  • 한 페이지당 4개의 카드를 가지고 있어야 하기 때문에 위와 같이 itemCout와 itemBuilder에 연산식을 넣어 주었다.
  • 아래 PageView를 return하는 컬럼을 확인하면 for () ... [ ] 형태의 식을 볼 수 있는데, 이와 같은 형태로 반복문 또는 조건문을 사용해줄 수 있다. ( 조건문일 경우 if () ... [] )

 

bottomNavgationBar 구현

 

 

bottomNavigationBar는 Scaffold의 bottomNavigationBar 속성에서 구현하였다. bottomNavigationBar는 기본적으로 flutter에서 제공되는 위젯이 훌륭하기 때문에 구현의 난이도가 굉장히 낮은 편에 속했다. 

 

import 'package:flutter/material.dart';

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

  @override
  State<CustomBottomNavigationBar> createState() =>
      _CustomBottomNavigationBarState();
}

class _CustomBottomNavigationBarState extends State<CustomBottomNavigationBar> {
  int _selectedIndex = 0;

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      items: <BottomNavigationBarItem>[
        BottomNavigationBarItem(
          icon: _selectedIndex == 0
              ? Icon(Icons.home_filled)
              : Icon(Icons.home_outlined),
          label: '홈',
        ),
        BottomNavigationBarItem(
          icon: _selectedIndex == 1
              ? Icon(Icons.play_arrow)
              : Icon(Icons.play_arrow_outlined),
          label: '샘플',
        ),
        BottomNavigationBarItem(
          icon: _selectedIndex == 2
              ? Icon(Icons.send_and_archive)
              : Icon(Icons.send_and_archive_outlined),
          label: '둘러보기',
        ),
        BottomNavigationBarItem(
          icon: _selectedIndex == 3
              ? Icon(Icons.library_music)
              : Icon(Icons.library_music_outlined),
          label: '보관하기',
        ),
      ],
      currentIndex: _selectedIndex,
      selectedItemColor: Colors.white,
      onTap: _onItemTapped,
      type: BottomNavigationBarType.fixed,
      backgroundColor: Color.fromARGB(255, 14, 14, 14),
      showUnselectedLabels: true,
      unselectedItemColor: Colors.white,
      selectedLabelStyle: TextStyle(color: Colors.white),
    );
  }
}

 

  • 현재 탭일 경우 아이콘 변경 (outline -> filled)
  • 대부분의 앱에서 BottomNavigationBar와 Device의 하단 system버튼들의 background 색상을 동일하게 맞춰주기 때문에 main에서 SystemChrome을 사용하여 Device 버튼 배경색을 변경해 주었다.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:youtube_music/constants.dart';
import 'package:youtube_music/screens/home/home_screen.dart';

void main() {
  SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
      systemNavigationBarColor: Color.fromARGB(255, 14, 14, 14)));
  runApp(const MyApp());
}

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Youtube Music',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark().copyWith(
        primaryColor: primaryColor,
        scaffoldBackgroundColor: bgColor,
        canvasColor: bgColor,
      ),
      home: HomeScreen(),
    );
  }
}

 

 

 

마무리

 

첫 번째 프로젝트로 Youtube Music앱을 따라만들어 보았다. 반응형도 아니고, 에뮬레이터 기준으로 만들었기 때문에 실제 상용될 수 있는 UI를 만들었다기 보다는, Sliver를 이용해서 UI를 그리는 연습을 한 셈이다. 다음 번 프로젝트에서는 조금 더 완성도 있는 결과물을 만들어낼 예정이다.