① build.gradle에 아래의 의존성을 추가한다.
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
}
dependencies {
implementation("androidx.room:room-migration:2.6.0")
implementation("androidx.room:room-runtime:2.6.0")
kapt("androidx.room:room-compiler:2.6.0")
}
② Song을 아래와 같이 수정한다.
@Entity(tableName = "SongTable")
data class Song(
var title: String = "",
var singer: String = "",
var second: Int = 0,
var playTime: Int = 0,
var isPlaying: Boolean = false,
var music: String = "",
var coverImg: Int? = null,
var isLike: Boolean = false
){
@PrimaryKey(autoGenerate = true) var id: Int = 0
}
③ SongDao 인터페이스를 추가한다.
@Dao
interface SongDao {
@Insert
fun insert(song: Song)
@Update
fun update(song: Song)
@Delete
fun delete(song: Song)
@Query("SELECT * FROM SongTable")
fun getSongs(): List<Song>
@Query("SELECT * FROM SongTable WHERE id = :id")
fun getSong(id: Int): Song
}
※ DAO
DAO는 Data Access Object의 약자로, DB의 data에 접근하기 위한 객체를 의미한다. 즉, 직접 DB에 접근하여 데이터를 삽입, 삭제, 조회 등의 DML 기능을 수행한다. (JPA에서의 Repository와 비슷한 개념으로 생각해도 된다.)
④ abstract class로 SongDatabase를 추가한다.
@Database(entities = [Song::class], version = 1)
abstract class SongDatabase: RoomDatabase() {
abstract fun songDao(): SongDao
companion object {
private var instance: SongDatabase? = null
@Synchronized
fun getInstance(context: Context): SongDatabase? {
if (instance == null) {
synchronized(SongDatabase::class){
instance = Room.databaseBuilder(
context.applicationContext,
SongDatabase::class.java,
"song-database" // 다른 데이터 베이스랑 이름이 겹치지 않도록 주의
).allowMainThreadQueries().build() // 편의상 메인 쓰레드에서 처리하게 한다.
}
}
return instance
}
}
}
⑤ MainActivity를 아래와 같이 수정한다.
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
inputDummySongs()
activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val data = result.data
if (data != null) {
val message = data.getStringExtra("message")
Log.d("message", message!!)
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
}
...
binding.mainPlayerCl.setOnClickListener {
val editor = getSharedPreferences("song", MODE_PRIVATE).edit()
editor.putInt("songId", song.id)
editor.apply()
val intent = Intent(this,SongActivity::class.java)
activityResultLauncher.launch(intent)
}
...
override fun onStart() {
super.onStart()
val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
// songId는 SongActivity에서 onPause가 호출되었을 때
// 재생 중이던 노래의 id(pk)이다.
val songId = sharedPreferences.getInt("songId", 0)
// SongDatabase의 인스턴스를 가져온다.
val songDB = SongDatabase.getInstance(this)!!
song = if (songId == 0){ // 재생 중이던 노래가 없었으면
songDB.songDao().getSong(1)
} else{ // 재생 중이던 노래가 있었으면
songDB.songDao().getSong(songId)
}
Log.d("song ID", song.id.toString())
setMiniPlayer(song) // song의 정보로 MiniPlayer를 setting
}
...
private fun inputDummySongs(){
val songDB = SongDatabase.getInstance(this)!!
val songs = songDB.songDao().getSongs()
// songs에 데이터가 이미 존재해 더미 데이터를 삽입할 필요가 없음
if (songs.isNotEmpty()) return
// songs에 데이터가 없을 때에는 더미 데이터를 삽입해주어야 함
songDB.songDao().insert(
Song(
"Lilac",
"아이유 (IU)",
0,
200,
false,
"music_lilac",
R.drawable.img_album_exp2,
false,
)
)
songDB.songDao().insert(
Song(
"Flu",
"아이유 (IU)",
0,
200,
false,
"music_flu",
R.drawable.img_album_exp2,
false,
)
)
songDB.songDao().insert(
Song(
"Butter",
"방탄소년단 (BTS)",
0,
190,
false,
"music_butter",
R.drawable.img_album_exp,
false,
)
)
songDB.songDao().insert(
Song(
"Next Level",
"에스파 (AESPA)",
0,
210,
false,
"music_next",
R.drawable.img_album_exp3,
false,
)
)
songDB.songDao().insert(
Song(
"Boy with Luv",
"music_boy",
0,
230,
false,
"music_boy",
R.drawable.img_album_exp4,
false,
)
)
songDB.songDao().insert(
Song(
"BBoom BBoom",
"모모랜드 (MOMOLAND)",
0,
240,
false,
"music_bboom",
R.drawable.img_album_exp5,
false,
)
)
// DB에 데이터가 잘 들어갔는지 확인
val songDBData = songDB.songDao().getSongs()
Log.d("DB data", songDBData.toString())
}
}
⑥ SongActivity를 아래와 같이 수정한다.
class SongActivity : AppCompatActivity() {
lateinit var binding : ActivitySongBinding
lateinit var timer : Timer
private var mediaPlayer : MediaPlayer? = null // 추후에 미디어 플레이어 해제를 위해 nullable로 선언
val songs = arrayListOf<Song>()
lateinit var songDB: SongDatabase
var nowPos = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySongBinding.inflate(layoutInflater)
setContentView(binding.root)
initPlayList() // SongDB에 있는 모든 노래를 songs에 저장
// 재생 중이던 노래가 있으면 해당 노래의 정보로 화면을 구성하고,
// 재생 중이던 노래가 없으면 0번 index의 노래의 정보로 화면을 구성한다.
initSong()
binding.songMiniplayerIv.setOnClickListener {
setPlayerStatus(true)
startStopService()
}
binding.songPauseIv.setOnClickListener {
setPlayerStatus(false)
startStopService()
}
}
override fun onBackPressed() {
val intent = Intent(this, MainActivity::class.java)
intent.putExtra("message", "뒤로가기 버튼 클릭")
setResult(RESULT_OK, intent)
finish()
}
override fun onPause() {
super.onPause()
songs[nowPos].second = (songs[nowPos].playTime * binding.songProgressSb.progress) / 100000
songs[nowPos].isPlaying = false
setPlayerStatus(false)
val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putInt("songId", songs[nowPos].id)
editor.apply()
}
override fun onDestroy() {
super.onDestroy()
timer.interrupt()
mediaPlayer?.release() // 미디어 플레이어가 갖고 있던 리소스를 해제한다.
mediaPlayer = null // 미디어 플레이어를 해제한다.
}
private fun initPlayList(){
songDB = SongDatabase.getInstance(this)!!
songs.addAll(songDB.songDao().getSongs())
}
private fun initClickListener(){
binding.songDownIb.setOnClickListener {
val intent = Intent(this, MainActivity::class.java)
intent.putExtra("message", songs[nowPos].title + "_" + songs[nowPos].singer)
setResult(RESULT_OK, intent)
finish()
}
binding.songMiniplayerIv.setOnClickListener {
setPlayerStatus(true)
}
binding.songPauseIv.setOnClickListener {
setPlayerStatus(false)
}
}
private fun initSong() { // intent 방식 사용 안함
val spf = getSharedPreferences("song", MODE_PRIVATE)
val songId = spf.getInt("songId", 0)
nowPos = getPlayingSongPosition(songId)
Log.d("now Song ID", songs[nowPos].id.toString())
startTimer()
setPlayer(songs[nowPos])
}
// songId로 position을 얻는 메서드
private fun getPlayingSongPosition(songId: Int): Int{
for (i in 0 until songs.size){
if (songs[i].id == songId){
return i
}
}
return 0
}
private fun setPlayer(song : Song) {
binding.songMusicTitleTv.text = song.title
binding.songSingerNameTv.text = song.singer
binding.songStartTimeTv.text = String.format("%02d:%02d", song.second / 60, song.second % 60)
binding.songEndTimeTv.text = String.format("%02d:%02d", song.playTime / 60, song.playTime % 60)
binding.songAlbumIv.setImageResource(song.coverImg!!)
binding.songProgressSb.progress = (song.second * 1000 / song.playTime)
val music = resources.getIdentifier(song.music, "raw", this.packageName)
mediaPlayer = MediaPlayer.create(this, music)
setPlayerStatus(song.isPlaying)
}
fun setPlayerStatus (isPlaying : Boolean){
songs[nowPos].isPlaying = isPlaying
timer.isPlaying = isPlaying
if(isPlaying){ // 재생중
binding.songMiniplayerIv.visibility = View.GONE
binding.songPauseIv.visibility = View.VISIBLE
mediaPlayer?.start()
} else { // 일시정지
binding.songMiniplayerIv.visibility = View.VISIBLE
binding.songPauseIv.visibility = View.GONE
if(mediaPlayer?.isPlaying == true) { // 재생 중이 아닐 때에 pause를 하면 에러가 나기 때문에 이를 방지
mediaPlayer?.pause()
}
}
}
private fun startStopService() {
if (isServiceRunning(ForegroundService::class.java)) {
Toast.makeText(this, "Foreground Service Stopped", Toast.LENGTH_SHORT).show()
stopService(Intent(this, ForegroundService::class.java))
}
else {
Toast.makeText(this, "Foreground Service Started", Toast.LENGTH_SHORT).show()
startService(Intent(this, ForegroundService::class.java))
}
}
private fun isServiceRunning(inputClass : Class<ForegroundService>) : Boolean {
val manager : ActivityManager = getSystemService(
Context.ACTIVITY_SERVICE
) as ActivityManager
for (service : ActivityManager.RunningServiceInfo in manager.getRunningServices(Integer.MAX_VALUE)) {
if (inputClass.name.equals(service.service.className)) {
return true
}
}
return false
}
private fun startTimer() {
timer = Timer(songs[nowPos].playTime, songs[nowPos].isPlaying)
timer.start()
}
inner class Timer(private val playTime: Int, var isPlaying: Boolean = true) : Thread() {
private var second : Int = 0
private var mills : Float = 0F
override fun run() {
super.run()
try {
while(true) {
if(second >= playTime) {
break
}
while (!isPlaying) {
sleep(200) // 0.2초 대기
}
if(isPlaying) {
sleep(50)
mills += 50
runOnUiThread {
// binding.songProgressSb.progress = ((mills/playTime*1000) * 100).toInt()
binding.songProgressSb.progress = ((mills/playTime) * 100).toInt()
}
if(mills % 1000 == 0F) { // 1초
runOnUiThread {
binding.songStartTimeTv.text = String.format("%02d:%02d", second / 60, second % 60)
}
second++
}
}
}
} catch (e: InterruptedException) {
Log.d("SongActivity", "Thread Terminates! ${e.message}")
}
}
}
}
SongActivity에 아래의 내용을 추가한다.
override fun onCreate(savedInstanceState: Bundle?) {
initPlayList()
initSong()
initClickListener()
...
private fun initClickListener(){
...
binding.songNextIv.setOnClickListener {
moveSong(+1)
}
binding.songPreviousIv.setOnClickListener {
moveSong(-1)
}
}
private fun moveSong(direct: Int) { // direct는 +1 또는 -1임
if (nowPos + direct < 0) {
Toast.makeText(this,"first song",Toast.LENGTH_SHORT).show()
}
else if (nowPos + direct >= songs.size){
Toast.makeText(this,"last song",Toast.LENGTH_SHORT).show()
}
else {
nowPos += direct
timer.interrupt()
startTimer()
mediaPlayer?.release()
mediaPlayer = null
setPlayer(songs[nowPos])
}
}
}
코드를 실행시켜보면, SongActivity에서 다음 버튼을 클릭했을 때, 다음 곡으로 잘 넘어가고, Prgoress Bar도 잘 초기화되는 것을 확인할 수 있다. 당연히 노래도 잘 재생된다.
① SongDao에 아래의 쿼리를 추가한다.
@Query("UPDATE SongTable SET isLike= :isLike WHERE id = :id")
fun updateIsLikeById(isLike: Boolean,id: Int)
② SongActivity에 아래의 내용을 추가한다.
override fun onCreate(savedInstanceState: Bundle?) {
...
binding.songLikeIv.setOnClickListener {
setLike(songs[nowPos].isLike)
}
...
private fun setLike(isLike: Boolean){
songs[nowPos].isLike = !isLike
songDB.songDao().updateIsLikeById(!isLike,songs[nowPos].id)
if (!isLike){
binding.songLikeIv.setImageResource(R.drawable.ic_my_like_on)
} else{
binding.songLikeIv.setImageResource(R.drawable.ic_my_like_off)
}
}
private fun setPlayer(song : Song) {
binding.songMusicTitleTv.text = song.title
binding.songSingerNameTv.text = song.singer
binding.songStartTimeTv.text = String.format("%02d:%02d", song.second / 60, song.second % 60)
binding.songEndTimeTv.text = String.format("%02d:%02d", song.playTime / 60, song.playTime % 60)
binding.songAlbumIv.setImageResource(song.coverImg!!)
binding.songProgressSb.progress = (song.second * 1000 / song.playTime)
val music = resources.getIdentifier(song.music, "raw", this.packageName)
mediaPlayer = MediaPlayer.create(this, music)
if(song.isLike) {
binding.songLikeIv.setImageResource(R.drawable.ic_my_like_on)
}
else {
binding.songLikeIv.setImageResource(R.drawable.ic_my_like_off)
}
setPlayerStatus(song.isPlaying)
}
...
코드를 실행시켜보면, 좋아요 및 좋아요 취소 기능이 잘 동작할 것이다. 이 좋아요 데이터는 앱을 껐다가 다시 실행하더라도 그대로 유지된다.
이전 포스팅에서 LockerFragment > SavedSongFragment에 RecyclerView로 아이템을 나타내고, 일시적으로 삭제하는 방법에 대해 학습하였다. 이번 포스팅에서는 SavedSongFragment의 RecyclerView 아이템에 좋아요 표시한 음악만 보이도록 수정하고, 더 보기 버튼 클릭 시 아이템이 영구적으로 삭제되도록 만들어보겠다.
① LockerAlbumRVAdpater를 아래와 같이 수정한다.
class LockerAlbumRVAdapter () : RecyclerView.Adapter<LockerAlbumRVAdapter.ViewHolder>() {
private val switchStatus = SparseBooleanArray()
private val songs = ArrayList<Song>()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): LockerAlbumRVAdapter.ViewHolder {
val binding: ItemLockerAlbumBinding = ItemLockerAlbumBinding
.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: LockerAlbumRVAdapter.ViewHolder, position: Int) {
holder.bind(songs[position])
holder.binding.itemLockerAlbumMoreIv.setOnClickListener {
itemClickListener.onRemoveAlbum(songs[position].id) // 좋아요 취소로 업데이트하는 메서드
removeSong(position) // 현재 화면에서 아이템을 제거
}
val switch = holder.binding.switchRV
switch.isChecked = switchStatus[position]
Log.d("SparseBooleanArray", switch.isChecked.toString())
switch.setOnClickListener {
if (switch.isChecked) {
switchStatus.put(position, true)
}
else {
switchStatus.put(position, false)
}
notifyItemChanged(position)
}
}
override fun getItemCount(): Int = songs.size
inner class ViewHolder(val binding: ItemLockerAlbumBinding): RecyclerView.ViewHolder(binding.root){
fun bind(song : Song){
binding.itemLockerAlbumTitleTv.text = song.title
binding.itemLockerAlbumSingerTv.text = song.singer
binding.itemLockerAlbumCoverImgIv.setImageResource(song.coverImg!!)
}
}
interface OnItemClickListener {
fun onRemoveAlbum(songId: Int)
}
private lateinit var itemClickListener : OnItemClickListener
fun setItemClickListener(onItemClickListener: OnItemClickListener) {
this.itemClickListener = onItemClickListener
}
@SuppressLint("NotifyDataSetChanged") // 경고 무시 어노테이션
fun addSongs(songs: ArrayList<Song>) {
this.songs.clear()
this.songs.addAll(songs)
notifyDataSetChanged()
}
@SuppressLint("NotifyDataSetChanged")
private fun removeSong(position: Int){
songs.removeAt(position)
notifyDataSetChanged()
}
}
② SongDao에 아래의 쿼리를 추가한다.
@Query("SELECT * FROM SongTable WHERE isLike= :isLike")
fun getLikedSongs(isLike: Boolean): List<Song>
③ SavedSongFragment를 아래와 같이 수정한다.
class SavedSongFragment : Fragment() {
lateinit var songDB: SongDatabase
lateinit var binding : FragmentSavedSongBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentSavedSongBinding.inflate(inflater, container, false)
songDB = SongDatabase.getInstance(requireContext())!!
return binding.root
}
override fun onStart() {
super.onStart()
initRecyclerview()
}
private fun initRecyclerview(){
binding.lockerMusicAlbumRv.layoutManager = LinearLayoutManager(requireActivity())
val lockerAlbumRVAdapter = LockerAlbumRVAdapter()
lockerAlbumRVAdapter.setItemClickListener(object : LockerAlbumRVAdapter.OnItemClickListener {
override fun onRemoveAlbum(songId: Int) {
songDB.songDao().updateIsLikeById(false, songId)
}
})
binding.lockerMusicAlbumRv.adapter = lockerAlbumRVAdapter
lockerAlbumRVAdapter.addSongs(songDB.songDao().getLikedSongs(true) as ArrayList<Song>)
}
}
이제 코드를 실행시켜보면, SavedSongFragment에 내가 좋아요 표시한 음악만 나타나는 것을 확인할 수 있다. 또한, 더 보기 버튼을 눌러 해당 아이템을 목록에서 영구 삭제하는 것도 가능하다.
MainActivity에서도 song이 아닌 songs[nowPos]로 변경해야 한다.
① SongActivity의 onPause 메서드를 아래와 같이 수정한다.
override fun onPause() {
super.onPause()
songs[nowPos].second = (songs[nowPos].playTime * binding.songProgressSb.progress) / 100000
Log.d("second", songs[nowPos].second.toString())
songs[nowPos].isPlaying = false
setPlayerStatus(false)
val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putInt("songId", songs[nowPos].id)
editor.putInt("second", songs[nowPos].second)
editor.apply()
}
② MainActivity를 아래와 같이 수정하자.
class MainActivity : AppCompatActivity() {
lateinit var binding : ActivityMainBinding
lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
val songs = arrayListOf<Song>()
lateinit var songDB: SongDatabase
var nowPos = 0
override fun onCreate(savedInstanceState: Bundle?) {
...
inputDummySongs()
initPlayList()
initBottomNavigation()
...
override fun onResume() {
super.onResume()
val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
val songId = sharedPreferences.getInt("songId", 0)
nowPos = getPlayingSongPosition(songId)
setMiniPlayer(songs[nowPos])
}
...
private fun getPlayingSongPosition(songId: Int): Int{
for (i in 0 until songs.size){
if (songs[i].id == songId){
return i
}
}
return 0
}
private fun initPlayList(){
songDB = SongDatabase.getInstance(this)!!
songs.addAll(songDB.songDao().getSongs())
}
private fun setMiniPlayer(song : Song) {
binding.mainMiniplayerTitleTv.text = song.title
binding.mainMiniplayerSingerTv.text = song.singer
Log.d("songInfo", song.toString())
val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
val second = sharedPreferences.getInt("second", 0)
Log.d("spfSecond", second.toString())
binding.mainMiniplayerProgressSb.progress = (second * 100000 / song.playTime)
}
...
이제 코드를 실행시켜보자. 노래를 재생시킨 후 앱을 종료하거나 뒤로가기 버튼을 누르면, MiniPlayer의 SeekBar가 재생한 시간만큼 이동하여 있을 것이다.
Album 테이블과 Song 테이블 간의 일대다관계를 매핑한다. 여기서는 같은 Album에 수록된 Song에 대해, 같은 albumIdx 값을 갖게 하는 방식을 사용하여 간단하게 매핑을 구현할 것이다.
① Song data class에 albumIdx 필드를 추가한다.
val albumIdx : Int = 0
② Album data class를 아래와 같이 수정한다.
@Entity(tableName = "AlbumTable")
data class Album(
@PrimaryKey(autoGenerate = false) var id: Int = 0,
var title: String? = "",
var singer: String? = "",
var coverImg: Int? = null
)
③ AlbumDao 인터페이스를 생성한다.
@Dao
interface AlbumDao {
@Insert
fun insert(album: Album)
@Update
fun update(album: Album)
@Delete
fun delete(album: Album)
@Query("SELECT * FROM AlbumTable") // 테이블의 모든 값을 가져와라
fun getAlbums(): List<Album>
@Query("SELECT * FROM AlbumTable WHERE id = :id")
fun getAlbum(id: Int): Album
}
④ SongDatabase를 아래와 같이 수정한다.
@Database(entities = [Song::class, Album::class], version = 1)
abstract class SongDatabase: RoomDatabase() {
abstract fun albumDao(): AlbumDao
abstract fun songDao(): SongDao
...
⑤ MainActivity의 inputDummyData 메서드를 아래와 같이 수정한다.
private fun inputDummySongs(){
val songDB = SongDatabase.getInstance(this)!!
val songs = songDB.songDao().getSongs()
if (songs.isNotEmpty()) return
songDB.songDao().insert(
Song(
"Lilac",
"아이유 (IU)",
0,
200,
false,
"music_lilac",
R.drawable.img_album_exp2,
false,
1
)
)
songDB.songDao().insert(
Song(
"Flu",
"아이유 (IU)",
0,
200,
false,
"music_flu",
R.drawable.img_album_exp2,
false,
1
)
)
songDB.songDao().insert(
Song(
"Butter",
"방탄소년단 (BTS)",
0,
190,
false,
"music_butter",
R.drawable.img_album_exp,
false,
2
)
)
songDB.songDao().insert(
Song(
"Next Level",
"에스파 (AESPA)",
0,
210,
false,
"music_next",
R.drawable.img_album_exp3,
false,
3
)
)
songDB.songDao().insert(
Song(
"Boy with Luv",
"music_boy",
0,
230,
false,
"music_boy",
R.drawable.img_album_exp4,
false,
4
)
)
songDB.songDao().insert(
Song(
"BBoom BBoom",
"모모랜드 (MOMOLAND)",
0,
240,
false,
"music_bboom",
R.drawable.img_album_exp5,
false,
5
)
)
val songDBData = songDB.songDao().getSongs()
Log.d("DB data", songDBData.toString())
}
⑥ HomeFragment를 아래와 같이 수정한다.
class HomeFragment : Fragment(), CommunicationInterface {
lateinit var binding : FragmentHomeBinding
private var albumDatas = ArrayList<Album>()
private lateinit var songDB: SongDatabase
...
override fun onCreateView(
...
songDB = SongDatabase.getInstance(requireContext())!!
albumDatas.addAll(songDB.albumDao().getAlbums())
Log.d("albumlist", albumDatas.toString())
val albumRVAdapter = AlbumRVAdapter(albumDatas)
binding.homeTodayMusicAlbumRv.adapter = albumRVAdapter
binding.homeTodayMusicAlbumRv.layoutManager = LinearLayoutManager(requireActivity(), LinearLayoutManager.HORIZONTAL, false)
...
private fun inputDummyAlbums(){
val songDB = SongDatabase.getInstance(requireActivity())!!
val songs = songDB.albumDao().getAlbums()
if (songs.isNotEmpty()) return
songDB.albumDao().insert(
Album(
1,
"IU 5th Album 'LILAC'",
"아이유 (IU)",
R.drawable.img_album_exp2
)
)
songDB.albumDao().insert(
Album(
2,
"Butter",
"방탄소년단 (BTS)",
R.drawable.img_album_exp
)
)
songDB.albumDao().insert(
Album(
3,
"iScreaM Vol.10: Next Level Remixes",
"에스파 (AESPA)",
R.drawable.img_album_exp3
)
)
songDB.albumDao().insert(
Album(
4,
"Map of the Soul Persona",
"뮤직 보이 (Music Boy)",
R.drawable.img_album_exp4,
)
)
songDB.albumDao().insert(
Album(
5,
"Great!",
"모모랜드 (MOMOLAND)",
R.drawable.img_album_exp5
)
)
val songDBData = songDB.albumDao().getAlbums()
Log.d("DB data", songDBData.toString())
}
}
⑦ item_album.xml을 아래와 같이 수정한다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp">
<androidx.cardview.widget.CardView
android:id="@+id/item_album_cover_img_cardView"
android:layout_width="150dp"
android:layout_height="150dp"
app:cardCornerRadius="7dp"
app:cardElevation="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageView
android:id="@+id/item_album_cover_img_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:src="@drawable/img_album_exp2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.cardview.widget.CardView>
<ImageView
android:id="@+id/item_album_play_img_iv"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="5dp"
android:src="@drawable/widget_black_play"
app:layout_constraintBottom_toBottomOf="@id/item_album_cover_img_cardView"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/item_album_title_tv"
android:layout_width="150dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="LILAC"
android:textColor="@color/black"
android:textSize="20sp"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintStart_toStartOf="@id/item_album_cover_img_cardView"
app:layout_constraintTop_toBottomOf="@id/item_album_cover_img_cardView" />
<TextView
android:id="@+id/item_album_singer_tv"
android:layout_width="150dp"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="아이유 (IU)"
android:textColor="#a8a8a8"
android:textSize="15sp"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintStart_toStartOf="@id/item_album_cover_img_cardView"
app:layout_constraintTop_toBottomOf="@id/item_album_title_tv" />
</androidx.constraintlayout.widget.ConstraintLayout>
스낵바는 토스트와 비슷하게 화면에 잠깐 보였다가 사라지는 팝업 메시지이다. 하지만 토스트 메시지와 달리 화면 전환이 이뤄지면 메시지가 사라지고, 버튼을 사용할 수도 있다. 더구나 Custom Toast가 불가능해지면서, SnackBar를 사용하는 경우가 더욱 늘었다. 지금부터 SnackBar를 커스터마이징하는 방법에 대해 알아보기로 하자.
① drawable 디렉토리 하위로, custom_snackbar_bg라는 이름의 리소스 파일을 추가한다.
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape
android:shape="rectangle">
<solid
android:color="@color/colorPrimaryGrey"/>
<corners
android:radius="5dp"/>
</shape>
</item>
</selector>
② custom_snackbar.xml 파일을 layout 디렉토리 하위에 추가하고, 아래의 내용을 입력한다.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="300dp"
android:layout_height="wrap_content"
android:paddingStart="15dp"
android:paddingEnd="15dp"
android:background="@drawable/custom_snackbar_bg">
<TextView
android:id="@+id/custom_snackbar_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Custom SnackBar!"
android:layout_marginRight="20dp"
android:padding="10dp"
android:textColor="@android:color/white"
android:textSize="17sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/custom_snackbar_btn"
android:layout_width="70dp"
android:layout_height="40dp"
android:text="OK"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
③ 앱 전체에서 공통으로 스낵바 메시지를 사용하기 위해 CustomSnackbar 클래스를 생성한다.
class CustomSnackbar(view: View, private val message: String) {
companion object {
fun make(view: View, message: String) = CustomSnackbar(view, message)
}
private val context = view.context
private val snackbar = Snackbar.make(view, "", 5000)
private val snackbarLayout = snackbar.view as Snackbar.SnackbarLayout
private val inflater = LayoutInflater.from(context)
private val snackbarBinding: CustomSnackbarBinding
= DataBindingUtil.inflate(inflater, R.layout.custom_snackbar, null, false)
init {
initView()
initData()
}
private fun initView() {
with(snackbarLayout) {
removeAllViews()
setPadding(0, 0, 0, 0)
setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent))
addView(snackbarBinding.root, 0)
}
}
private fun initData() {
snackbarBinding.customSnackbarTv.text = message
snackbarBinding.customSnackbarBtn.setOnClickListener {
// OK 버튼을 클릭했을 때 실행할 동작을 정의할 수 있다.
}
}
fun show() {
snackbar.show()
}
}
④ SongActivity의 moveSong 메서드의 Toast 메시지를 Custom SnackBar 메시지로 변경해보자.
private fun moveSong(direct: Int) { // +1 또는 -1
if (nowPos + direct < 0) {
CustomSnackbar.make(binding.root, "처음 곡입니다.").show()
// Toast.makeText(this,"처음 곡입니다.",Toast.LENGTH_SHORT).show()
}
else if (nowPos + direct >= songs.size){
CustomSnackbar.make(binding.root, "마지막 곡입니다").show()
// Toast.makeText(this,"마지막 곡입니다.",Toast.LENGTH_SHORT).show()
}
이제 코드를 실행시켜보자. SongActivity에서 좋아요 버튼을 클릭하면 아래와 같은 Custom SnackBar 메시지가 나타날 것이다.
구현이 복잡한 관계로 Bottom Sheet Dialog를 띄우는 방법에 대해서만 간략히 소개하고 넘어가기로 한다.
① 먼저 Module 수준의 build.gradle 파일에 아래의 의존성을 추가한다.
implementation("com.google.android.material:material:1.10.0")
② BottomSheetFragment를 생성한다.
③ fragment_bottom_sheet.xml에 아래의 내용을 입력한다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
tools:context=".BottomSheetFragment"
android:background="@color/select_color">
<TextView
android:id="@+id/bottom_sheet_tv1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="60dp"
android:gravity="center"
android:text="듣기"
android:background="@color/transparent"
android:textSize="15sp"
android:textColor="@color/black"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_tv2"
app:layout_constraintTop_toTopOf="parent"/>
<ImageView
android:id="@+id/bottom_sheet_iv1"
android:layout_width="0dp"
android:layout_height="50dp"
android:src="@drawable/btn_miniplayer_play"
app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv1"
app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_iv2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/bottom_sheet_tv2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="60dp"
android:background="@color/transparent"
android:gravity="center"
android:text="재생목록"
android:textColor="@color/black"
android:textSize="15sp"
app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_tv3"
app:layout_constraintStart_toEndOf="@+id/bottom_sheet_tv1"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/bottom_sheet_iv2"
android:layout_width="0dp"
android:layout_height="50dp"
android:src="@drawable/btn_miniplayer_play"
app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv2"
app:layout_constraintStart_toEndOf="@+id/bottom_sheet_iv1"
app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_iv3"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/bottom_sheet_tv3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="60dp"
android:background="@color/transparent"
android:gravity="center"
android:text="내 리스트"
android:textColor="@color/black"
android:textSize="15sp"
app:layout_constraintEnd_toStartOf="@+id/bottom_sheet_tv4"
app:layout_constraintStart_toEndOf="@+id/bottom_sheet_tv2"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/bottom_sheet_iv3"
android:layout_width="0dp"
android:layout_height="50dp"
android:src="@drawable/btn_miniplayer_play"
app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv2"
app:layout_constraintStart_toEndOf="@+id/bottom_sheet_iv2"
app:layout_constraintEnd_toStartOf="@id/bottom_sheet_iv4"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/bottom_sheet_tv4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="60dp"
android:background="@color/transparent"
android:gravity="center"
android:text="삭제"
android:textColor="@color/black"
android:textSize="15sp"
app:layout_constraintStart_toEndOf="@+id/bottom_sheet_tv3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/bottom_sheet_iv4"
android:layout_width="0dp"
android:layout_height="50dp"
android:src="@drawable/btn_miniplayer_play"
app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_tv2"
app:layout_constraintStart_toEndOf="@+id/bottom_sheet_iv3"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
④ BottomSheetFragment에 아래의 내용을 입력한다.
class BottomSheetFragment : BottomSheetDialogFragment() {
lateinit var binding : FragmentBottomSheetBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentBottomSheetBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.bottomSheetIv1.setOnClickListener {
Toast.makeText(requireActivity(), "듣기 버튼 클릭", Toast.LENGTH_SHORT).show()
}
binding.bottomSheetIv2.setOnClickListener {
Toast.makeText(requireActivity(), "재생목록 버튼 클릭", Toast.LENGTH_SHORT).show()
}
binding.bottomSheetIv3.setOnClickListener {
Toast.makeText(requireActivity(), "내 리스트 버튼 클릭", Toast.LENGTH_SHORT).show()
}
binding.bottomSheetIv4.setOnClickListener {
Toast.makeText(requireActivity(), "삭제 버튼 클릭", Toast.LENGTH_SHORT).show()
}
}
}
⑤ LockerFragment의 전체 선택 버튼을 클릭했을 때, Bottom Sheet Dialog가 나타나도록 만들자.
val bottomSheetFragment = BottomSheetFragment()
binding.lockerSelectAllTv.setOnClickListener {
bottomSheetFragment.show(requireFragmentManager(), "BottomSheetDialog")
}
코드를 실행시켜보면, 아래와 같이 전체 선택 버튼을 클릭했을 때, Bottom Sheet Dialog가 나타나는 것을 확인할 수 있다. 이외의 부가적인 기능들은 직접 구현해보기 바란다.
파이어베이스의 Authentication, Realtime Database, Storage, Cloud Messaging에 대한 사용법은 이미 포스팅한 바 있으니, 아래의 링크를 참조하기 바란다.
>> Authetication
>> Realtime Database
>> Storage
>> Cloud Messaging