1. 안드로이드 뮤직 플레이어 앱 만들기 with ExoPlayer, Kotlin
이번에는 멜론 같은 안드로이드 뮤직 재생 앱을 만들어 보는 프로젝트를 진행해보았습니다. 서버 에서 음악 정보들을 받아와 재생 목록을 구성하고 재생 및 일시정지, 다음 곡, 이전 곡, 곡 위치 탐색 등의 기본적인 기능을 수행할 수 있도록 구현했습니다.
주요 기능
- 음악 서버에서 음악 받아와 재생 목록 구성하기
- 현재 재생 중인 음악 재생 목록에서 표시 (회색 배경)
- 재생/일시정지, 이전 곡/다음 곡 재생 기능
- seekBar 를 통한 음악 탐색 기능
- 재생 목록 <-> 음악 뷰 전환 가능
사용 기술
- Exoplayer
- 커스텀 컨트롤러
- playlist
- androidx.constraintlayout.widget.Group
기타
- SeekBar Custom
- postDelayed, TimeUnit
결과 화면
2. 기본 레이아웃 구성
사용된 레이아웃 자원은 메인 화면, 플레이어 프레그먼트 그리고 리사이클러 뷰 홀더에 사용 될 음악 아이템 레이아웃 이렇게 3개로 정의해두었습니다.
[activity_main.xml]
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
...>
<FrameLayout
android:id="@+id/fragment_container"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
메인 레이아웃은 player_fragment 를 담을 FrameLayout 으로 가득차게 구성을 했는데 미래에 재사용성을 위해 이런 구성을 사용했습니다.
[fragment_player.xml : 뒷 배경 구성 하기]
<View
android:id="@+id/top_background_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/background"
app:layout_constraintBottom_toTopOf="@id/bottom_background_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_weight="3" />
<View
android:id="@+id/bottom_background_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/top_background_view"
app:layout_constraintVertical_weight="2" />
다음은 플레이어 프래그먼트입니다. 나눠진 배경의 경우 view 2개로 구성하여 위에 회색, 아래 흰색으로 layout_constraintVertical_weight
속성을 사용해 3:2 비율로 수직으로 채워지도록 설정해주어 위와 같이 만들어줍니다.
[fragment_player.xml : Group 사용 해보기]
<androidx.constraintlayout.widget.Group
android:id="@+id/player_view_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="track_text_view, artist_text_view,
cover_image_card_view, bottom_background_view,
player_seek_bar, play_time_text_view, total_time_text_view"
tools:visibility="visible"
android:visibility="gone"/>
<androidx.constraintlayout.widget.Group
android:id="@+id/play_list_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="play_list_recycler_view, title_text_view, play_list_seek_bar"
android:visibility="visible"
/>
재생화면과 플레이어 뷰를 따로 나누지 않고 한 프레그먼트 안에서 그룹으로 묶어 플레이 리스트 전환 아이콘을 터치 할 때 관련된 뷰들만 보여주도록 구현했습니다.
[fragment_player.xml : 앨범 커버 이미지 CardView]
<androidx.cardview.widget.CardView
android:id="@+id/cover_image_card_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="36dp"
android:layout_marginEnd="36dp"
android:translationY="30dp"
app:cardCornerRadius="5dp"
app:cardElevation="10dp"
app:layout_constraintBottom_toBottomOf="@id/top_background_view"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageView
android:id="@+id/cover_image_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/purple_200" />
</androidx.cardview.widget.CardView>
앨범 커버 이미지 영역을 위해 카드뷰를 사용하여 둥근 모양, 바닥에서 띄워진 그림자 효과 등을 이용할 수 있습니다. 다음 속성으로 cardElevation
바닥에서 띄워서 그림자 생성, cardCornerRadius
둥근 모양 형성이 가능합니다.
[fragment_player.xml : exoplayer2]
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="0dp"
android:layout_height="100dp"
android:alpha="0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:use_controller="false" />
엑소 플레이어의 경우에는 컨트롤러를 사용할 수 있지만, 해당 프로젝트에서는 따로 커스텀 버튼을 만들어서 사용하도록 해주었습니다. (사실 컨트롤러를 사용하면 더욱 쉽게 구현이 가능할 수 있습니다.)
그 외로는 seekBar, 버튼(재생, 다음/이전 곡), 리사이클러 뷰에 사용될 아이템 레이아웃 등을 구현을 해주었습니다. (전체 코드는 저의 깃허브 저장소에서 확인가능합니다.)
2.1. Main activity
Fragment를 상속한 PlayerFragment를 구현한 뒤 메인 엑티비티의 프래그먼트 컨테이너에 할당해주어 뮤직 플레이어 프레그먼트가 최초 실행 시 보이도록 설정합니다.
class MainActivity : AppCompatActivity() {
private var _viewBinding: ActivityMainBinding? = null
private val viewBinding: ActivityMainBinding get() = requireNotNull(_viewBinding)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
supportFragmentManager.beginTransaction()
.replace(viewBinding.fragmentContainer.id, PlayerFragment.newInstance())
.commit()
}
}
requireNotNull()
을 사용해서 nullable이 제거된 바인딩을 사용할 수 있도록 get()을 가진 프로퍼티를 하나 정의해주었습니다.
[PlayerFragment.kt]
class PlayerFragment : Fragment(R.layout.fragment_player) {
// 코드 생략...
companion object {
fun newInstance(): PlayerFragment {
return PlayerFragment()
}
}
}
PlayerFragment를 직접 생성해서 사용할 수도 있지만, companion object로 newInstance 를 만드는 정적 매서드를 하나 추가해서 새로운 인스턴스를 공급 받는 형태로 구현해주었습니다.
2.2. RecyclerView (item_layout, Adapter, Model)
리사이클러뷰는 현재 까지 제 블로그에 올려진 앱들을 만들어 보면서 활용을 많이 하였기에 넘어갈 수도 있지만, 그래도 한 번 간단하게 집고 가도록 하겠습니다.
item_music.xml
뷰홀더에 사용될 아이템의 경우 사용 뮤직앱들과 비슷하게 앨범 커버, 타이틀, 아티스트로 이루어진 레이아웃으로 구성했습니다.
Model (data class)
data class MusicModel (
val id: Long,
val track: String,
val streamUrl: String,
val artist: String,
val coverUrl: String,
val isPlaying: Boolean = false
)
모델으로는 곡의 정보를 담고 있는 data class를 하나 정의해주었습니다. isPlaying 이라는 불 변수를 하나 추가해 현재 재생되고 있는지 상태를 나타내주었습니다. (이후 해당 상태로 아이템 뷰의 배경색을 결정합니다.)
RecyclerView Adapter
class MusicAdapter(private val callback: (MusicModel) -> Unit): ListAdapter<MusicModel, MusicAdapter.ViewHolder>(diffUtil) {
inner class ViewHolder(private val itemMusicBinding: ItemMusicBinding):RecyclerView.ViewHolder(itemMusicBinding.root){
fun bind(music: MusicModel){
itemMusicBinding.itemArtistTextView.text = music.artist
itemMusicBinding.itemTrackTextView.text = music.track
Glide.with(itemMusicBinding.itemCoverImageView.context)
.load(music.coverUrl)
.into(itemMusicBinding.itemCoverImageView)
// 재생 중에 따라
if(music.isPlaying){
// itemView 를 사용했는데 이건 리사이클러 뷰에서 뷰홀더(아이템 하나) 현재 아이템에 해당
itemView.setBackgroundColor(Color.GRAY) // 재생중이면 배경 색을 회색
}else{
itemView.setBackgroundColor(Color.TRANSPARENT)
}
itemView.setOnClickListener {
callback(music)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemMusicBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(currentList[position])
}
companion object{
val diffUtil = object : DiffUtil.ItemCallback<MusicModel>(){
override fun areItemsTheSame(oldItem: MusicModel, newItem: MusicModel): Boolean { // id 값만 비교
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: MusicModel, newItem: MusicModel): Boolean { // 내부 내용 비교
return oldItem == newItem
}
}
}
}
어뎁터는 리스트 어뎁터를 상속해 구현해주었고 생성자로 콜백함수를 받아 아이템 클릭시 해당 콜백을 실행하도록 구현해주었습니다.
앨범 커버 이미지 로드는 Glide 라이브러리를 활용해 구현해주었습니다.
뷰 홀더의 경우 inner class로 정의해주었고 안드로이드에서 권장하는 뷰 바인딩을 사용해주었습니다. RecyclerView.ViewHolder
를 상속해 처음 생성 시 사용 뷰를 넘겨주어야 하기 때문에 바인딩의 루트 뷰를 넘겨주면 됩니다.
diffUtil을 통해서 아이템의 동일 여부 및 내용의 동일 여부를 판단하는데 해당 프로젝트에서는 이를 잘 활용하는데 이후 재생 되는 곡이 변경 될 때 isPlaying 이 변경된 리스트를 갱신하는데 새로운 객체를 할당해 옳바르게 재생 중인 음악이 표시되도록 data class
의 copy()
를 활용합니다.
2.3. SeekBar customizing
<SeekBar
android:id="@+id/player_seek_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="50dp"
android:layout_marginEnd="50dp"
android:layout_marginBottom="30dp"
android:maxHeight="4dp"
android:minHeight="4dp"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:progressDrawable="@drawable/player_seek_background"
android:thumb="@drawable/player_seek_thumb"
app:layout_constraintBottom_toTopOf="@id/player_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:progress="40" />
progressDrawable
으로 프로그레스 표시를 커스터마이징 할 수 있습니다.
thumb
으로 thumb를 커스터마이징 할 수 있습니다. (thumb는 음악 탐색 시 끌거나 당기거나 하는 그 부분(?) 입니다.)
[player_seek_background.xml]
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background"> <!-- 재정의 -->
<shape>
<corners android:radius="2dp" />
<solid android:color="@color/seek_background" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="2dp" />
<stroke
android:width="2dp"
android:color="@color/purple_200" />
<solid android:color="@color/purple_200" />
</shape>
</clip>
</item>
</layer-list>
background, progress를 재정의 해준 코드입니다.
[player_seek_thumb.xml]
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/purple_200" />
<size
android:width="4dp"
android:height="4dp" />
</shape>
thumb를 재정의 해준 코드입니다. 다 기본적으로 보라색 한 줄 처럼(?) 표시되도록 하기 위해 위와 같이 정의해주었습니다.
플레이 리스트에서 표시할 하단 seekbar도 비슷하게 progressTint
, thumbTint
을 보라색을 주었고 clickable="false"
을 주어 클릭할 수 없게 만들었습니다.
3. 음악 서버 (저작권 없는 음반, mocky)
음악의 경우에는 저작권에 민감하기 때문에 저작권이 없는 음악을 사용하였고 서버를 따로 구축하기 보다는 mocky를 사용하여 음악 정보 리스트를 가진 json 을 받아 이를 통해 음악 플레이리스트를 구성해주도록 했습니다.
3.1. Retrofit 사용 하기 (Dto, Entity, Service)
Retrofit 또한 마찬가지로 기본적인 서버 데이터를 받아올 때 많이 사용했었습니다! (이전 앱 만들기 프로젝트들 참고) 핵심 코드만 보고 넘어가겠습니다.
[MusicDto]
data class MusicDto (
val musics: List<MusicEntity> // 서버에서 받아올 데이터
)
[MusicEntity]
data class MusicEntity(
@SerializedName("track")val track: String,
@SerializedName("streamUrl")val streamUrl: String,
@SerializedName("artist")val artist: String,
@SerializedName("coverUrl")val coverUrl: String
)
속성명이 동일하지만 @SerializedName 이 사용될 수도 있다라는 것을 알고가면 좋기 때문에 사용해주었습니다.
[MusicService]
interface MusicService {
@GET("/v3/e4db045a-23a9-4b49-a3fc-78cf51f3f964")
fun listMusics(): Call<MusicDto>
}
[PlayerFragment.kt : 서버에서 데이터 가져오기]
private fun getVideoListFromServer() {
val retrofit = Retrofit.Builder()
.baseUrl("https://run.mocky.io/")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(MusicService::class.java)
.also {
it.listMusics()
.enqueue(object : Callback<MusicDto> {
override fun onResponse(
call: Call<MusicDto>,
response: Response<MusicDto>
) {
response.body()?.let { musicDto ->
model = musicDto.mapper()
setMusicList(model.getAdapterModels())
adapter.submitList(model.getAdapterModels())
}
}
override fun onFailure(call: Call<MusicDto>, t: Throwable) {
}
})
}
}
베이스 url 을 지정, gson 컨버터 사용하도록 하여 Retrofit을 빌드하고 MusicService
를 사용해 생성 및 응답 처리를 해줍니다.
이때 response.body()
에서 musicDto
를 받아오는데 mapper를 이용하여 바로 모델로 바꾸어 사용할 수 있도록 해주었습니다. 아래는 사용된 맵퍼 함수 입니다.
fun MusicEntity.mapper(id: Long): MusicModel =
MusicModel(id = id, track, streamUrl, artist, coverUrl)
fun MusicDto.mapper(): PlayerModel =
PlayerModel(
playMusicList = musics.mapIndexed { index, musicEntity ->
musicEntity.mapper(index.toLong())
}
)
기존 데이터에 id만 새로 갱신해서 넣어주도록 해줍니다.
4. Exoplayer 사용 해보기
Exoplayer는 이전 유튜브 앱 만들어 보기 글 에서 한 번 다루어 본 적이 있습니다. 이번에는 SimpleExoPlayer
를 사용해 보도록 하겠습니다.
private var player: SimpleExoPlayer? = null
SimpleExoPlayer 형식의 player 라는 변수를 하나 정의하고 null을 할당합니다.
context?.let {
player = SimpleExoPlayer.Builder(it).build()
}
빌드 시 context
가 필요하기 때문에 context
를 널 체크 해준뒤 이를 사용해서 빌드해줍니다.
binding.playerView.player = player
레이아웃에서 playerView 로 지정해준 아이디 (엑소 플레이어의 ui playerview)에 빌드해준 player를 대입해줍니다.
미디어 아이템 설정하기 : addMediaItems
private fun setMusicList(modelList: List<MusicModel>) {
player ?: return
context?.let {
player?.addMediaItems(modelList.map { musicModel ->
MediaItem.Builder()
.setMediaId(musicModel.id.toString()) // 미디어 아이디를 musicModel id로
.setUri(musicModel.streamUrl)
.build()
/*
미디어 아이템에 2가지 태그 지정 가능
미디어 id, 뷰에 태그 지정했듯 미디어 아이템에 태그 지정 가능
*/
})
player?.prepare()
}
}
이전 프로젝트 에서는 Exoplayer를 사용할 때 재생할 동영상을 하나씩 설정해서 재생해주었는데 이번 뮤직 앱 프로젝트에서는 MediaItems
를 추가해서 마치 플레이리스트를 가진 플레이어를 사용하는 것 처럼 사용해주기 위해 addMediaItems
로 미디어 아이템을 추가해주었습니다.
음악 서버에서 음악 정보를 받아오면 이를 통해 MusicModel 리스트를 받아서 이를 player의 미디어 아이템으로 추가해주었습니다.
이때 MediaId로 뮤직 모델의 아이디를 설정해서 이후 구분 가능하게 사용할 수 있도록 설정해주었습니다.
setUri로 음악이 재생될 주소를 설정해주고 빌드하면 미디어 아이템이 생성됩니다.
이후 플레이어를 prepare를 통해 준비해줍니다.
4.1. Listener 추가 해보기
player?.addListener(object : Player.EventListener {
}
플레이어에는 addListener
를 통해서 Player.EventListener
를 구현하는 익명 객체를 넣어주면 쉽게 구현할 수 있습니다.
이후 필요한 이벤트 리스너를 재정의 해서 구현해주면 되겠습니다.
플레이어가 재생 또는 일시 정지 될 때 : onIsPlayingChanged
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
if (isPlaying) {
binding.playControlImageView.setImageResource(R.drawable.ic_baseline_pause_48)
} else {
binding.playControlImageView.setImageResource(R.drawable.ic_baseline_play_arrow_24)
}
}
플레이어가 재생 또는 일시정지 상태가 되면 재생/일시정지 버튼의 아이콘을 알맞게 보여주도록 해주었습니다.
미디어 아이템이 바뀔 때 : onMediaItemTransition
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
val newIndex: String = mediaItem?.mediaId ?: return
model.currentPosition = newIndex.toInt()
adapter.submitList(model.getAdapterModels())
// 리사이클러 뷰 스크롤 이동
binding.playListRecyclerView.scrollToPosition(model.currentPosition)
updatePlayerView(model.currentMusicModel())
}
onMediaItemTransition
을 재정의 하여 미디어 아이템이 바뀔 때 마다 아이디로 지정했던 mediaId를 가져와서 리사이클러 뷰를 갱신(재생하고 있는 곡은 회색 배경으로 전환) 플레이어뷰를 갱신(이미지로드, 제목, 아티스트 텍스트 갱신) 합니다.
UX가 자연스러워 질 수 있게 다음/이전 곡 버튼을 통해 곡을 이동하면 리사이클러 뷰의 스크롤 포지션도 변경해서 재생 중인 곡으로 이동하도록 해주었습니다.
재생, 재생완료, 버퍼링 등의 상태 변화 시 : inPlaybackStateChanged
override fun onPlaybackStateChanged(state: Int) {
super.onPlaybackStateChanged(state)
updateSeek()
}
재생 중일 때 seekBar의 잔량과 상태를 갱신해주기 위해서 해당 체인지 리스너를 재정의해주었습니다.
private fun updateSeek() {
val player = this.player ?: return
val duration = if (player.duration >= 0) player.duration else 0 // 전체 음악 길이
val position = player.currentPosition
updateSeekUi(duration, position)
val state = player.playbackState
view?.removeCallbacks(updateSeekRunnable)
// 재생 중 일때 (재생 중이 아니거나, 재생이 끝나지 않은 경우)
if (state != Player.STATE_IDLE && state != Player.STATE_ENDED) {
view?.postDelayed(updateSeekRunnable, 1000) // 1초에 한번씩 실행
}
}
updateSeek
에서는 음악 길이 정보를 통해서 seekBar ui를 업데이트하고 1초 마다 updateSeekRunnable
을 실행합니다. updateSeekRunnable
은 단지 updateSeek()
을 호출합니다.
그래서 재생 중이면 해당 1초마다 seekBar를 갱신해서 여타 뮤집앱 처럼 탐색바가 갱신되도록 해주는 것 이지요. 해당 Runnable
이 겹쳐 동작하지 않도록 updateSeek()
을 타면 removeCallbacks
를 통해 기존에 있던 Runnable
을 제거해줍니다.
TimeUnit Convert
private fun updateSeekUi(duration: Long, position: Long) {
binding.playListSeekBar.max = (duration / 1000).toInt() // 총 길이를 설정. 1000으로 나눠 작게
binding.playListSeekBar.progress = (position / 1000).toInt() // 동일하게 1000으로 나눠 작게
binding.playerSeekBar.max = (duration / 1000).toInt()
binding.playerSeekBar.progress = (position / 1000).toInt()
binding.playTimeTextView.text = String.format(
"%02d:%02d",
TimeUnit.MINUTES.convert(position, TimeUnit.MILLISECONDS), // 현재 분
(position / 1000) % 60 // 분 단위를 제외한 현재 초
)
binding.totalTimeTextView.text= String.format(
"%02d:%02d",
TimeUnit.MINUTES.convert(duration, TimeUnit.MILLISECONDS), // 전체 분
(duration / 1000) % 60 // 분 단위를 제외한 초
)
}
updateSeekUi()
에서는 전체 길이와 현재 위치로 식바를 갱신합니다. max
와 progress
를 원래 위치 값에 1000을 나눠 너무 큰 값이 되지 않게 해줍니다.
이후 시간을 표시하는 텍스트는 String.format()
매서드를 통해 남은 시간 총 시간을 분:초 형식으로 0을 채운 2자리수로 나타내주도록 합니다.
이때 java.util
의 TimeUnit
을 사용하여 밀리초를 분으로 변경해주고 남은 초를 계산해서 텍스트로 뿌려줄 수 있습니다.
4.2. seekBar로 탐색
binding.playerSeekBar.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener{
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
player?.seekTo(seekBar.progress * 1000L)
}
})
탐색 바를 통한 음악 탐색의 경우 setOnSeekBarChangeListener
를 설정하여 터치를 멈출 때 즉 손을 뗄 때 해당 위치의 progress
를 플레이어에 설정해서 구현해줍니다.
5. 생명주기를 통한 리소스 관리
의도하지 않은 작업 (백 그라운드로 나갔는데도 계속 재생 등) 및 자원 처리를 위해서 생명주기를 활용합니다.
override fun onStop() {
super.onStop()
player?.pause()
view?.removeCallbacks(updateSeekRunnable)
}
사용자가 백그라운드로 나가거나 다른 앱을 사용하는 경우 재생을 멈추고 seekBar를 업데이트하는 콜백을 제거합니다.
override fun onDestroy() {
super.onDestroy()
_binding = null
player?.release()
view?.removeCallbacks(updateSeekRunnable)
}
앱이 완전히 종료되는 경우 바인딩 해제, 플레이어 해제, 콜백 제거를 통한 처리를 진행합니다.
2021.07.13 - [Android/App] - [Android] Youtube 앱 만들어보기 (ExoPlayer, MotionLayout)
'Android > App' 카테고리의 다른 글
[Android] K-MOOC 강좌정보 서비스 앱 (2021 app dev-matching) (0) | 2021.08.18 |
---|---|
[Android] 구글맵 API를 사용한 어플 만들어보기 (위치 검색, 현위치) (2) | 2021.08.17 |
[Android] Youtube 앱 만들어보기 (ExoPlayer, MotionLayout) (2) | 2021.07.13 |
[Android] 에어비앤비 앱 만들어 보기 (네이버 지도 api, mocky 등) (0) | 2021.06.30 |
[Android] 중고 거래 앱 만들기 (중고 물품 등록, 채팅, 로그인) (3) | 2021.06.24 |