[DIP/FE] Playlist 추천 결과 - 리스트형 & 갤러리형 보기(ViewPager2)

2026. 3. 8. 14:39·TAVE-16th

Ⅰ. 개요

우리 앱은 플레이리스트 추천 결과 화면은 리스트형, 갤러리형 등 2가지 방식으로 음악을 탐색할 수 있도록 설계했다. 리스트형으로 추천된 음악을 빠르게 확인할 수 있고, 갤러리형으로 앨범 이미지가 넘어가는 애니메이션으로 보다 몰입감 있는 경험을 제공한다.

 

리스트형 갤러리형

 

리스트형 갤러리형

사용자는 우측 맨 위에 있는 버튼을 눌러 언제든지 원하는 모드로 바꿀 수 있다.

추천된 플레이리스트는 Spotify에서 바로 재생할 수 있도록 딥링크 기능을 제공한다. 화면 상단의 "Spotify 전체 듣기" 버튼을 누르면 Spotify이 실행되고(앱이 없는 경우 웹사이트로), 추천된 플레이리스트를 재생할 수 있다.

하단에는 플레이리스트를 라이브러리에 저장할 수 있는 버튼이 있다. 사용자가 지금 추천된 플레이리스트가 마음에 들 경우 다시 찾기 쉽도록 앱 내부 라이브러리에 저장한다. 이때 자신의 취향에 맞게 플레이리스트 이름을 수정해 저장할 수 있다. 라이브러리에 저장한 플레이리스트는 언제든 다시 확인하고 딥링크로 넘어갈 수 있다.




 

Ⅱ. 리스트형(ListFragment) 구현

1) 데이터 매핑 및 UI 구성

서버에서 받은 RecommendationResponse를 앱 내에서 사용하는 MusicModel로 변환하고, 상단에 4개의 앨범 커버를 2X2로 배치한다.

private fun updateUIWithRealData(data: RecommendationResponse, place: String, goal: String) {
    // 영문 목표명을 한글로 변환 (UI 친화적)
    val koreanGoal = translateGoal(goal) 
    binding.tvSubtitle.text = "$place · $koreanGoal"
    binding.tvPlaylistName.text = data.playlistName ?: "맞춤 플레이리스트"

    // 서버 DTO -> UI 모델 변환
    val uiList = data.songs.map { song ->
        MusicModel(title = song.title, artist = song.artistName, albumCover = song.imageUrl, trackUri = song.uri)
    }

    setupRecyclerView(uiList)
    setupHeaderImages(uiList) // 상단 4분할 커버 이미지 로드
}

 

2) Spotify 딥링크 & 라이브러리 저장

추천받은 곡들을 실제로 들을 수 있게 스포티파이로 넘어갈 수 있는 딥링크를 제공한다. Intent.ACTION_VIEW를 사용해 스포티파이로 유저를 보내주고, 동시에 서버에 분석용 로그(Analytics)도 전송했다.

// Spotify 전체 듣기 버튼
binding.btnDeepLinkSpotify.setOnClickListener {
    // 어떤 플리가 클릭됐는지 서버에 알림 (분석용)
    RetrofitClient.recommendationApi.sendAnalytics(data.playlistId.toString()).enqueue(...)

    val spotifyUrl = data.playlistUrl
    if (!spotifyUrl.isNullOrEmpty()) {
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(spotifyUrl))
        startActivity(intent)
    }
}

// 라이브러리 저장 (이름 수정 바텀시트 호출)
binding.btnAddLibrary.setOnClickListener {
    showAddLibraryBottomSheet(binding.tvPlaylistName.text.toString())
}

 

3) 라이브러리에 저장하기 위한 이름 수정 함수

이름 수정하는 바텀시트 저장 완료 시 토스트
// --- 이름 수정 바텀시트 ---
    private fun showAddLibraryBottomSheet(currentName: String) {
        val bottomSheetDialog = BottomSheetDialog(requireContext())
        val view = requireActivity().layoutInflater.inflate(R.layout.bottom_sheet_edit_name, null)
        bottomSheetDialog.setContentView(view)

        val etName = view.findViewById<EditText>(R.id.etPlaylistName)
        val btnConfirm = view.findViewById<View>(R.id.btnConfirmEdit)
        val btnClose = view.findViewById<View>(R.id.btnClose)

        etName.setText(currentName)
        etName.setSelection(currentName.length)

        btnClose.setOnClickListener {
            bottomSheetDialog.dismiss()
        }

        btnConfirm.setOnClickListener {
            val newName = etName.text.toString().trim()
            binding.tvPlaylistName.text = newName

            // 커스텀 토스트 (아이콘 포함)
            val layout = layoutInflater.inflate(R.layout.toast_custom, null)
            val iconView = layout.findViewById<ImageView>(R.id.iv_toast_icon)
            iconView?.visibility = View.VISIBLE

            context?.let { ctx ->
                RecommendationManager.savePlaylistName(ctx, newName)
            }
            val savedPlaylistId = context?.let { ctx ->
                RecommendationManager.getPlaylistId(ctx)
            } ?: ""

            updatePlaylistNameOnServer(savedPlaylistId, newName)

            showCustomToast("내 라이브러리에 추가됐어요")
            bottomSheetDialog.dismiss()

            binding.btnAddLibrary.visibility = View.GONE
            binding.btnMoveToLibrary.visibility = View.VISIBLE
        }
        bottomSheetDialog.show()
    }


// --- 서버에 수정한 이름 보내기 ---
    private fun updatePlaylistNameOnServer(playlistId: String, newName: String) {
        val requestBody = UpdatePlaylistNameRequest(newPlaylistName = newName)

        RetrofitClient.recommendationApi.updatePlaylistName(playlistId, requestBody).enqueue(object : Callback<BaseResponse<String>> {
            override fun onResponse(call: Call<BaseResponse<String>>, response: Response<BaseResponse<String>>) {
                if (!response.isSuccessful) {
                    Toast.makeText(context, "서버 저장에 실패했습니다.", Toast.LENGTH_SHORT).show()
                }
            }
            override fun onFailure(call: Call<BaseResponse<String>>, t: Throwable) {
                // 로그 제거됨
            }
        })
    }

 

4) 동일한 조건으로 플레이리스트 다시 생성

현재 상황에 대한 플레이리스트 결과가 맘에 들지 않을 수 있는 점을 고려해 다시 추천하기 버튼을 만들었다. 장소, 소음, 목표 등은 현재 생성된 맥락과 동일한 상태에서 서버로 요청을 다시 보내는 것이다.

이때도 이전 포스팅에서 다룬 Polling 로직을 그래도 사용하였고, 기다리는 시간에는 원형 Progress Bar가 나타나도록 했다.

private fun showRegenerateBottomSheet() {
    val bottomSheetDialog = BottomSheetDialog(requireContext())
    // ... 바텀시트 설정 ...
    confirmBtn?.setOnClickListener {
        bottomSheetDialog.dismiss()
        binding.loadingProgressBar.visibility = View.VISIBLE // 프로그래스바
        startAsyncRegeneration() // 다시TaskId 받고 폴링 시작!
    }
}




 

Ⅲ. 갤러리형(GalleryFragment) 구현

1) ViewPager2와 트랜스포머 설정

갤러리형에서 가장 중요하게 생각했던 점은 화면 전환 애니메이션이다. CompositePageTransformer를 사용해 옆으로 밀 때 카드가 작아지면서 자연스럽게 뒤로 물러나는 효과를 구현했다.

private fun setupViewPagerSettings() {
    val viewPager = binding.vpGallery
    viewPager.offscreenPageLimit = 3 // 양옆 페이지 미리 로드
    viewPager.getChildAt(0).overScrollMode = RecyclerView.OVER_SCROLL_NEVER

    val compositePageTransformer = CompositePageTransformer()
    compositePageTransformer.addTransformer(MarginPageTransformer(20)) // 카드 사이 간격
    compositePageTransformer.addTransformer { page, position ->
        val r = 1 - abs(position)
        val scaleFactor = 0.50f + r * 0.50f // 가운데는 1.0, 양옆은 0.5 비율
        page.scaleY = scaleFactor
        page.scaleX = scaleFactor

        // 카드가 겹치지 않게 위치 보정(Offset)
        val myOffset = (page.width - (page.width * scaleFactor)) / 3
        if (position < 0) {
            page.translationX = myOffset
        } else {
            page.translationX = -myOffset
        }
    }
    viewPager.setPageTransformer(compositePageTransformer)
}

 

2) 데이터 매핑 & 무한 스크롤

곡 리스트를 어댑터에 연결하고, Int.MAX_VALUE를 활용해 유저가 사실상 무한히 넘겨볼 수 있도록 세팅했다. 시작 위치를 Int.MAX_VALUE / 2로 중간 어딘가로 잡아두면 유저는 플레이리스트의 음악을 계손 넘길 수 있다.

private fun updateUIWithSharedData(data: RecommendationResponse) {
    val musicList = data.songs.map { song ->
        MusicModel(title = song.title, artist = song.artistName, albumCover = song.imageUrl, trackUri = song.uri)
    }

    if (musicList.isEmpty()) return

    val adapter = GalleryAdapter(musicList)
    binding.vpGallery.adapter = adapter

    // 무한 스크롤 효과를 위해 시작 위치를 아주 큰 값의 중간으로 설정
    val centerPosition = Int.MAX_VALUE / 2
    val startPosition = centerPosition - (centerPosition % musicList.size)
    binding.vpGallery.setCurrentItem(startPosition, false)

    // 페이지가 바뀔 때마다 배경과 텍스트 업데이트
    binding.vpGallery.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
        override fun onPageSelected(position: Int) {
            val realPosition = position % musicList.size
            updateBackgroundAndText(musicList[realPosition])
        }
    })
}

 

3) 배경 블러 효과

페이지가 하나씩 넘어갈 때마다 하단의 곡 정보(tvCurrentTitle, tvCurrentArtist)도 바꾸고, 배경에는 블러를 입혀 그라데이션을 입혔다.

private fun updateBackgroundAndText(music: MusicModel) {
    binding.tvCurrentTitle.text = music.title
    binding.tvCurrentArtist.text = music.artist

    context?.let { ctx ->
        Glide.with(ctx)
            .load(music.albumCover)
            .apply(RequestOptions.bitmapTransform(BlurTransformation(25, 3))) // 강도 25, 샘플링 3으로 블러 처리
            .into(binding.ivBlurBackground)
    }
}

 

4) 딥링크 & 라이브러리 저장

리스트형과 동일하게 갤러리형에서도 스포티파이 딥링크 연결과 라이브러리 저장 기능을 동일하게 구현하였다.

// 라이브러리에 추가 버튼 클릭 시 바텀시트 호출
binding.btnAddLibrary.setOnClickListener {
    val currentName = binding.tvPlaylistName.text.toString()
    showAddLibraryBottomSheet(currentName)
}

// 스포티파이 딥링크 (분석 데이터 전송 포함)
binding.btnDeepLinkSpotify.setOnClickListener {
    RetrofitClient.recommendationApi.sendAnalytics(data.playlistId.toString()).enqueue(...)

    val spotifyUrl = data.playlistUrl
    if (!spotifyUrl.isNullOrEmpty()) {
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(spotifyUrl))
        startActivity(intent)
    }
}




 

Ⅳ. 마무리 회고

리스트형을 구현하는 건 무리가 없었지만, 갤러리형을 구현할 때 조금 애를 먹었다. 페이지가 하나씩 넘어가기 위한 viewpager 설정, 넘어갈 때마다 곡과 가수 정보도 바뀌어야 하고 뒷배경도 계속 바꿔줘야 한다. 이를 구현하는 게 어려웠던 것 같다.

그리고 화면에 보여지는 UI는 중앙에 큰 카드 1개, 좌우에 반 잘린 카드 2개도 있기 때문에 이 3개의 카드 사이의 transformer 수치를 지정해야 했다. 카드에 이미지를 로딩할 때 생기는 여백도 없애야 했다 ㅜㅜ

그럼에도 안드로이드 화면을 만든 것 중에 가장 예쁜 화면이라고 생각될 정도로 너무 예쁜 결과가 나와서 정말 뿌듯했다. 실제로도 데모데이 때 이 UI에 대한 칭찬이 많았다.

 

👾👉 DIP Android Github
📽️👉 풀시연영상 보러가기



 

'TAVE-16th' 카테고리의 다른 글

[DIP/FE] 둘러보기 - 카테고리별 API 호출하기  (0) 2026.03.08
[DIP/FE] 라이브러리 - Spotify에서 4분할 커버 가져오기 & 플리 삭제하기  (0) 2026.03.08
[DIP/FE] 노래 추천받기(2) - Polling 방식으로 서버 요청 보내기  (0) 2026.03.08
[DIP/FE] 노래 추천받기(1) - 장소 선택, 데시벨 측정, 목표 설정  (0) 2026.03.07
[DIP/FE] 온보딩 구현 - 문자열 검사 및 Spotify API로 아티스트 목록 가져오기  (0) 2026.03.07
'TAVE-16th' 카테고리의 다른 글
  • [DIP/FE] 둘러보기 - 카테고리별 API 호출하기
  • [DIP/FE] 라이브러리 - Spotify에서 4분할 커버 가져오기 & 플리 삭제하기
  • [DIP/FE] 노래 추천받기(2) - Polling 방식으로 서버 요청 보내기
  • [DIP/FE] 노래 추천받기(1) - 장소 선택, 데시벨 측정, 목표 설정
choisio2
choisio2
sio2-dev 님의 블로그 입니다.
  • choisio2
    SiO2 for Developer
    choisio2
  • 전체
    오늘
    어제
    • 분류 전체보기 (46) N
      • TAVE-16th (14)
      • BDA-11th (16)
      • C++ (5)
      • 개인 프로젝트 (4)
      • 백준 (4) N
      • 컴퓨터 그래픽스 (1)
      • 잡담 (1)
  • 블로그 메뉴

    • 태그
    • 방명록
  • 링크

    • github.com/choisio2
  • 공지사항

  • 인기 글

  • 태그

    KakaoOauth
    알고리즘
    백준
    geminicli
    SpotifyAPI
    BDA #데이터분석모델링
    데이터분석모델링
    백준1463
    androidstudio
    C++
    spotify
    알고리즘스터디
    BDA
    개발자미래
    AI시대
    프론트엔드
    Tave
    frontend
    코딩테스트
    playconsole
    calculator
    kotin
    kotlin
    viewpager2
    바이브코딩
    개발자
    polling
    코테
    BDAI
    데시벨측정
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
choisio2
[DIP/FE] Playlist 추천 결과 - 리스트형 & 갤러리형 보기(ViewPager2)
상단으로

티스토리툴바