① res 디렉토리 하위로 raw 디렉토리를 생성하고, 그 안에 라일락 mp3 파일을 넣는다.
② Song 클래스에 아래의 내용을 추가한다.
data class Song(
val title : String = "",
val singer : String = "",
var second: Int = 0,
var playTime: Int = 60,
var isPlaying : Boolean = false,
var music : String = "" // 재생할 MP3 파일의 이름
)
③ MainActivity에서 song을 생성할 때와 Intent로 전달할 때, music도 함께 전달해주어야 한다.
override fun onCreate(savedInstanceState: Bundle?) {
val song = Song(binding.mainMiniplayerTitleTv.text.toString(),
binding.mainMiniplayerSingerTv.text.toString(), 0, 60, false, "music_lilac")
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 intent = Intent(this, SongActivity::class.java)
intent.putExtra("title", song.title)
intent.putExtra("singer",song.singer)
intent.putExtra("second", song.second)
intent.putExtra("playTime", song.playTime)
intent.putExtra("isPlaying", song.isPlaying)
intent.putExtra("music", song.music)
activityResultLauncher.launch(intent)
}
}
④ SongActivity를 아래와 같이 수정한다.
class SongActivity : AppCompatActivity() {
lateinit var binding : ActivitySongBinding
lateinit var song : Song
lateinit var timer : Timer
// 추후에 미디어 플레이어를 해제하기 위해 nullable로 선언
private var mediaPlayer : MediaPlayer? = null
...
private fun initSong() {
if(intent.hasExtra("title") && intent.hasExtra("singer")) {
song = Song(
intent.getStringExtra("title")!!,
intent.getStringExtra("singer")!!,
intent.getIntExtra("second", 0),
intent.getIntExtra("playTime", 0),
intent.getBooleanExtra("isPlaying", false),
intent.getStringExtra("music")!!
)
}
startTimer()
}
private fun setPlayer(song : Song) {
binding.songMusicTitleTv.text = intent.getStringExtra("title")!!
binding.songSingerNameTv.text = intent.getStringExtra("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.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){
song.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
// 재생 중이 아닐 때, pause를 호출하면 에러가 나기 때문에 이를 방지하기 위한 조건문
if(mediaPlayer?.isPlaying == true) {
mediaPlayer?.pause()
}
}
}
...
이제 코드를 실행시켜보자. 재생 버튼을 누르면 음악이 재생되고 일시정지 버튼을 누르면 음악이 일시정지될 것이다.
현재는 SongActivity가 종료되거나, 홈 버튼을 눌러 보이지 않게 되더라도 음악이 계속해서 재생된다. SongActivity가 종료될 때에는 Activity LifeCycle의 onDestroy()가 호출되고, 홈 버튼을 눌렀을 때에는 onPause()가 호출되는데, 문제는 onDestroy가 호출될 때이다.
지난번 포스팅에서도 잠깐 설명했듯이 onDestroy가 호출되면, Activity에 할당된 쓰레드가 정리되기 때문에 Timer가 동작하지 않는다. 즉, 음악은 재생되고 있는데 재생 시간 및 SeekBar는 전혀 움직이지 않는, 다소 이상한 상황이 연출될 것이다.
이러한 문제를 해결하기 위해 LifeCycle의 개념을 사용할 수 있다. SongActivity에 아래의 내용을 추가한다.
override fun onPause() {
super.onPause()
setPlayerStatus(false)
}
override fun onDestroy() {
super.onDestroy()
timer.interrupt()
mediaPlayer?.release() // 미디어 플레이어가 갖고 있던 리소스를 해제한다.
mediaPlayer = null // 미디어 플레이어를 해제한다.
}
이제 코드를 실행시킨 후 음악을 재생시켜보자. SongActivity를 종료하거나, 홈 버튼을 누를 경우 음악이 멈추는 것을 확인할 수 있다.
SongActivity가 pause 될 때, song에 대한 정보를 SharedPreferences에 저장하고, 이 정보를 MainActivity의 재생 Bar에서 보여줄 수 있도록 만들어보자.
song의 title, singer, second, playTime 등의 정보를 일일이 SharedPreferences에 putString 하는 방식이 아닌, song을 Json으로 변환하여 통째로 SharedPrefernces에 putString 하는 방식을 사용하도록 하겠다.
① Module 수준의 build.gradle 파일에 gson 관련 의존성을 추가한다.
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
※ gson
Gson은 JSON(JavaScript Object Notation)과 Java 객체 간의 변환을 간단하게 처리할 수 있도록 도와주는 라이브러리로, Java 객체를 JSON 형식으로 직렬화하거나 JSON 데이터를 Java 객체로 역직렬화하는 작업을 수행한다.
② SongActivity에 아래의 내용을 추가한다.
class SongActivity : AppCompatActivity() {
lateinit var binding : ActivitySongBinding
lateinit var song : Song
lateinit var timer : Timer
// 추후에 미디어 플레이어 해제를 위해 nullable로 선언
private var mediaPlayer : MediaPlayer? = null
private var gson : Gson = Gson()
...
override fun onPause() {
super.onPause()
setPlayerStatus(false)
song.second = (song.playTime * binding.songProgressSb.progress) / 100000
val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
val editor = sharedPreferences.edit()
val songToJson = gson.toJson(song)
editor.putString("songData", songToJson)
Log.d("songData", songToJson.toString())
editor.apply()
}
...
이로써, SharedPreferences의 song에 대한 정보 저장을 완료하게 된다.
① MainActivity에서 몇 초까지 재생되었는지를 나타내기 위해 activity_main.xml에 SeekBar를 추가해주자.
<SeekBar
android:id="@+id/main_miniplayer_progress_sb"
android:layout_width="match_parent"
android:layout_height="10dp"
android:background="@null"
android:paddingEnd="0dp"
android:paddingStart="0dp"
android:layout_marginBottom="-4dp"
android:progressBackgroundTint="@color/song_player_bg"
android:progressTint="@color/song_player"
android:progress="0"
android:max="100000"
android:thumb="@color/transparent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/main_player_cl" />
② 이제부터는 song을 직접 생성하는 대신, sharedPreferences에서 가져올 것이기 때문에 MainActivity에서 아래의 내용을 삭제해주도록 하자.
val song = Song(binding.mainMiniplayerTitleTv.text.toString(),
binding.mainMiniplayerSingerTv.text.toString(), 0, 60, false, "music_lilac") 삭제
③ MainActivity를 아래와 같이 수정한다.
class MainActivity : AppCompatActivity() {
lateinit var binding : ActivityMainBinding
lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
private var song : Song = Song()
private var gson : Gson = Gson()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.Theme_UMCFlo)
...
override fun onStart() {
super.onStart()
val sharedPreferences = getSharedPreferences("song", MODE_PRIVATE)
val jsonToSong = sharedPreferences.getString("songData", null)
Log.d("jsonToSong", jsonToSong.toString())
song = if(jsonToSong == null) { // 최초 실행 시
Song("라일락", "아이유(IU)", 0, 60, false, "music_lilac")
} else { // SongActivity에서 노래가 한번이라도 pause 된 경우
gson.fromJson(jsonToSong, Song::class.java)
}
setMiniPlayer(song)
}
private fun setMiniPlayer(song : Song) {
binding.mainMiniplayerTitleTv.text = song.title
binding.mainMiniplayerSingerTv.text = song.singer
binding.mainMiniplayerProgressSb.progress = (song.second * 100000 / song.playTime)
}
}
이제 코드를 실행시켜보자. SongActivity에서 음악을 재생한 후, 홈 버튼을 눌러 onPause()를 호출한다. 이후 앱을 종료하고 다시 앱을 실행하면, 아래와 같이 MainActivity의 재생 Bar에서 음악이 몇 초까지 진행되었는지 나타나게 된다.
실제 FLO 앱에는 없지만, LifeCycle과 SharedPreferences를 조금 더 확실하게 학습해보자는 차원에서 메모장을 만드는 실습을 진행해보겠다. 별도의 프로젝트를 만들어도 되지만, 여기서는 FLO 앱 내에서 구현해보기로 한다.
① memo 작성에 사용될 아이콘을 memo라는 이름으로 drawable 디렉토리에 추가한다.
② fragment_home.xml 파일에 아래와 같이 아이콘을 배치한다.
<ImageView
android:id="@+id/home_pannel_btn_memo_iv"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginTop="20dp"
android:layout_marginLeft="20dp"
android:src="@drawable/memo"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
③ 메모장 화면을 구성하기 위해 MemoActivity를 생성한다.
④ HomeFragment에 위 아이콘에 대한 클릭 이벤트 리스너를 작성해주자.
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentHomeBinding.inflate(inflater, container, false)
...
binding.homePannelBtnMemoIv.setOnClickListener {
val intent = Intent(requireActivity(), MemoActivity::class.java)
startActivity(intent)
}
...
⑤ activity_memo.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"
tools:context=".MemoActivity">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:text="My Memo"
android:textSize="30sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="100dp"
app:counterMaxLength="200"
app:counterEnabled="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/memo_et"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="메모를 입력하세요" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/next_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginBottom="20dp"
android:text="작성 완료"
android:textSize="25sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
⑥ MemoCheckActivity를 생성한다.
⑦ MemoActivity에서 작성 완료 버튼을 클릭했을 때 MemoCheckActivity로 전환되도록 하는 클릭 이벤트 리스너를 추가하자.
class MemoActivity : AppCompatActivity() {
lateinit var binding : ActivityMemoBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMemoBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.nextBtn.setOnClickListener {
val intent = Intent(this, MemoCheckActivity::class.java)
val memoTxt = binding.memoEt.text.toString()
intent.putExtra("memo", memoTxt)
startActivity(intent)
}
}
}
⑧ activity_memo_check.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"
tools:context=".MemoCheckActivity">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:text="My Memo Check"
android:textSize="30sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/memo_check_text"
android:text="memo text"
android:gravity="center"
android:textSize="30sp"
android:textStyle="bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
⑨ 이제 MemoCheckActivity에서 intent로 전달받은 메모를 띄워주기만 하면 된다.
class MemoCheckActivity : AppCompatActivity() {
lateinit var binding : ActivityMemoCheckBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMemoCheckBinding.inflate(layoutInflater)
setContentView(binding.root)
if(intent.hasExtra("memo")) {
binding.memoCheckText.text = intent.getStringExtra("memo")!!
}
}
}
이제 코드를 실행시켜보자. MemoActivity에서 입력한 내용이 MemoCheckActivity에 잘 나타날 것이다.
메모를 작성하던 도중 어떠한 이유로 onPause가 호출되었다고 가정해보자. 이 때, onPause() 콜백 메서드를 이용하여 작성 중이던 메모를 SharedPreferences에 저장하면, 나중에 다시 앱을 실행할 때 메모를 이어서 작성할 수 있다. (만약, 아무 것도 작성하지 않은 상태에서 onPause가 호출되면, 아무 것도 저장하지 않는다.) 이 기능을 직접 구현해보도록 하겠다.
① MemoActivity의 onPause 메서드를 오버라이딩하자.
override fun onPause() {
super.onPause()
val sharedPreferences = getSharedPreferences("memo", MODE_PRIVATE)
val editor = sharedPreferences.edit()
val tempMemo = binding.memoEt.text.toString()
if(tempMemo.isNotEmpty()) {
editor.putString("tempMemo", tempMemo)
Log.d("tempMemo", tempMemo)
editor.apply()
}
}
② 재시작되었을 때, SharedPreferences에서 값을 가져오기 위해 onResume() 콜백 메서드도 오버라이딩 해주자.
override fun onResume() {
super.onResume()
val sharedPreferences = getSharedPreferences("memo", MODE_PRIVATE)
val tempMemo = sharedPreferences.getString("tempMemo", null)
if(tempMemo != null) {
binding.memoEt.setText(tempMemo)
}
}
이제 코드를 실행시킨 후 메모를 작성하다가 중간에 홈버튼을 누르거나, 액티비티가 종료시켜보자. 다시 메모장에 들어가면 작성 중이던 내용이 복원되는 것을 확인할 수 있을 것이다.
작성 중이던 메모가 있을 경우, 다이얼로그를 활용하여 사용자가 복원을 희망하는지 확인할 수 있다. 즉, "기존에 작성 중이던 메시지를 복원할까요?"라는 물음에 "네"로 응답하면 복원된 메모를 띄워주고, "아니오"로 응답하면 SharedPrefernences에서 기존 내용을 삭제하여 빈 메모를 띄워줄 것이다.
① 안드로이드 대화상자(Dialog)를 만들기 위해 layout 디렉토리 하위로 dialog라는 이름의 Layout Resource File을 추가한다.
② dialog.xml 파일에 아래의 내용을 입력한다.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/dialog_question"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="10dp"
android:text="기존에 작성 중인던\n메모가 있습니다."
android:textSize="25sp"
android:textStyle="bold"
android:textColor="#000000"
android:gravity="center"
android:background="@android:color/transparent"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="10dp"
android:text="복원하시겠습니까?"
android:textSize="20sp"
android:textColor="#000000"
android:gravity="center"
android:background="@android:color/transparent"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="100dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginTop="30dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<Button
android:id="@+id/yes"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="30dp"
android:layout_marginRight="10dp"
android:layout_weight="1"
android:text="Yes"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/no"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginRight="30dp"
android:layout_marginLeft="10dp"
android:layout_weight="1"
android:text="No"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
③ HomeFragment의 메모장 아이콘 클릭 이벤트 리스너를 아래와 같이 수정한다.
binding.homePannelBtnMemoIv.setOnClickListener {
val intent = Intent(requireActivity(), MemoActivity::class.java)
val activity = requireActivity() // fragment에서 SharedPreferences에 접근하려면 context가 필요함.
val sharedPreferences = activity.getSharedPreferences("memo", AppCompatActivity.MODE_PRIVATE)
val tempMemo = sharedPreferences.getString("tempMemo", null)
if(tempMemo != null) {
val dialogView = LayoutInflater.from(activity).inflate(R.layout.dialog, null)
val builder = AlertDialog.Builder(activity)
.setView(dialogView)
.setTitle("메모 복원하기")
val alertDialog = builder.show()
val yesBtn = alertDialog.findViewById<Button>(R.id.yes)
val noBtn = alertDialog.findViewById<Button>(R.id.no)
yesBtn!!.setOnClickListener {
startActivity(intent)
}
noBtn!!.setOnClickListener {
val editor = sharedPreferences.edit()
editor.remove("tempMemo")
editor.apply()
startActivity(intent)
}
} else {
startActivity(intent)
}
}
코드를 실행해보자. 작성 중이던 메모가 있을 경우 아래와 같이 Android Dialog가 표시된다. 이 때 Yes를 누르면, 기존 방식대로 복원된 메모가 나타나고, No를 누르면 저장된 내용이 사라지면서 빈 메모가 나타나게 된다.